errgonomic 0.9.3 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/CONTRIBUTING.md +1 -2
- data/README.md +26 -20
- data/Rakefile +1 -9
- data/lib/errgonomic/option.rb +142 -68
- data/lib/errgonomic/rails/active_record_optional.rb +19 -9
- data/lib/errgonomic/result.rb +105 -53
- data/lib/errgonomic/variant_name.rb +63 -0
- data/lib/errgonomic/version.rb +1 -1
- data/lib/errgonomic.rb +0 -37
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 543390fc5f85546ffde4785cfc41951400800094506078271cfe32535e9d784a
|
|
4
|
+
data.tar.gz: 8d5cdf061dbf7026eaab5ba8cab628368c600943699dbad04235c9d1d9bd3f5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad7cbe42d7f1d65549323c026a3f4486d4e64a9b5274023fc3eebb0a576ceb09c030151d176512220e13ef8d6e1bc3c2bff78162783ecb286bbb6805ac16c8c9
|
|
7
|
+
data.tar.gz: 4588909f84b800a1c497882d030fd88f23606146fe64b7ae02ecc8b7f4406ba2c5dd749d0c709c7004717de80a9a69e73d6247b2ce11013446fa25db66271c3d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.10.1] - 2026-09-10
|
|
4
|
+
|
|
5
|
+
This release makes cross-type equality raise unconditionally and removes the switch that used to turn it on.
|
|
6
|
+
|
|
7
|
+
### Upgrading from 0.10.0
|
|
8
|
+
|
|
9
|
+
`Errgonomic.strict_equality=`, `Errgonomic.strict_equality?` and `Errgonomic.with_strict_equality` are removed, along with `rake test:strict`, because cross-type equality always raises now. A `Some(x) == x`, `!= x`, `eql?(x)` or `=== x` anywhere in an application raises `Errgonomic::TypeMismatchError` where 0.10.0 answered `false` unless strict equality was switched on, and so does a collection operation that compares pairwise and asks the wrapper (`[Some(x)].include?(x)`, `Array#index`, `Array#-`, `Array#==`, `Hash#==`, `case x when Some(x)`) and a Minitest `assert_equal` whose expected value is a wrapper. Which operations raise, by which side holds the wrapper, is listed in the README's Strict equality section. Compare wrappers (`opt == Some(x)`), test the inner value (`opt.some_and? { |v| v == x }`), or `unwrap_or` a fallback first. Delete any `Errgonomic.strict_equality = true` in a test helper; it has nothing left to set.
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- [Behavior change] `==`, `!=`, `eql?` and `===` between an Option or a Result and a value that is not one always raise `Errgonomic::TypeMismatchError`, with the message 0.9.0 gave under the flag. `Some(1) == 1` answering `false` is the silent wrong branch that mirrors a wrapper written into a string, and 0.9.0 had the safe behavior opt-in. `===` is defined so `case 5 when Some(5)` and a pinned pattern raise under the operator that was written. `eql?` is not exempted: Ruby's hashing compares hash values first and asks `eql?` only of a candidate whose hash matches, so `Hash#[]`, `Set` and `uniq` stay quiet while `Array#include?`, `Array#-`, `Array#==` and `case/when` compare pairwise and raise when the wrapper is the operand asked. Strict equality never answers wrong, it only sometimes fails to catch, and `nil == Some(1)` is answered by `NilClass` and cannot be intercepted. The README states the whole surface
|
|
14
|
+
- `Errgonomic.strict_equality=`, `Errgonomic.strict_equality?` and `Errgonomic.with_strict_equality` are removed, which also removes a process-global flag that leaked across threads and whose block form's `ensure` could turn the mode off for a thread that had set it
|
|
15
|
+
- `Errgonomic.lenient_inner_value_comparison?` and `Errgonomic.give_me_lenient_inner_value_comparison=` are removed; nothing read them
|
|
16
|
+
- [Dev, Test] `rake test:strict` is removed, with its support file and CI step: the Rails integration suite runs under strict equality as `rake test`
|
|
17
|
+
|
|
18
|
+
## [0.10.0] - 2026-09-10
|
|
19
|
+
|
|
20
|
+
This release gives `deconstruct` the Rust shape and names the four variants at top level, so a pattern reads as `in Some(v)`.
|
|
21
|
+
|
|
22
|
+
### Upgrading from 0.9.3
|
|
23
|
+
|
|
24
|
+
`deconstruct` answers `[value]` for a `Some`, an `Ok` and an `Err` and `[]` for a `None`, so a pattern written against 0.9.x's `[self, value]` has to change shape: `in Errgonomic::Option::Some, v` becomes `in Some(v)`, `in Errgonomic::Result::Err, String => msg` becomes `in Err(String => msg)`, and `in Errgonomic::Option::None` stays as it is or becomes `in None`. `Some`, `None`, `Ok` and `Err` are now top-level names for the four variants, for patterns, as well as constructors. An application that defines its own constant under one of those names has to rename it: errgonomic refuses to load over one with a `NameError`, and a `class` or `module` of that name written after it loads raises `TypeError`.
|
|
25
|
+
|
|
26
|
+
### Changes
|
|
27
|
+
|
|
28
|
+
- [Behavior change] `deconstruct` answers `[value]` for a `Some`, an `Ok` and an `Err` and `[]` for a `None`, the one-payload shape `Data.define(:value)` and Rust's tuple variants share, where 0.9.x answered `[self, value]` and `[None]`. `in Some(v)` binds the value, `in Ok(Some(v))` nests, a two-branch `case/in` with no `else` is exhaustive, and a wrong type reaches `NoMatchingPatternError`. There is no `deconstruct_keys`: a one-payload sum type has no named field, and a `Some` around a Hash nests as `in Some({ id: })` through the Hash's own protocol
|
|
29
|
+
- A value-less `Err()` deconstructs to `[]`, so `in Err` and `in Err()` match it and `in Err(e)` matches only an `Err` that carries a value. The sentinel `Err()` holds in place of a value is internal, and a pattern variable must never bind it
|
|
30
|
+
- `Some`, `None`, `Ok` and `Err` are defined at top level beside the constructors of the same name, so a pattern reads as it does in Rust. Each is an `Errgonomic::VariantName` rather than the class it names: it matches as that class in a pattern and a `case/when`, and a bare one, written as Rust writes `return None`, refuses to stand in for a value. `to_s`, `to_json`, `as_json` and every ActiveRecord boundary that unwraps an Option raise `Errgonomic::SerializeError` on it, where the class would write `Errgonomic::Option::None` into a string, a column or a query, and a boolean column would store `true`. A constant of the same name that the application already defines stops the gem's load with a `NameError`, and a `class None` written after the gem loads raises `TypeError`, rather than one replacing or reopening the other. Rails defines none of the four
|
|
31
|
+
|
|
3
32
|
## [0.9.3] - 2026-09-10
|
|
4
33
|
|
|
5
34
|
This release removes the public `value` slot from `Some`, `Ok` and `Err`, and freezes every instance as it is constructed, so an Option or a Result is the value the README already said it was.
|
data/CONTRIBUTING.md
CHANGED
|
@@ -30,7 +30,6 @@ The inner rungs run constantly; the outer rungs are slower and run when preparin
|
|
|
30
30
|
bundle exec rubocop # formatted & lint-clean
|
|
31
31
|
bundle exec yard doctest # doctests pass — most behavior is specified here
|
|
32
32
|
bundle exec rake test # unit tests pass (incl. the Rails integration test)
|
|
33
|
-
bundle exec rake test:strict # the same suite with cross-type equality raising
|
|
34
33
|
```
|
|
35
34
|
|
|
36
35
|
A change that fails any of these is not ready. Keep formatting-only changes in their own commit so they do not obscure a behavioral diff. `bundle exec rake` runs the full suite (test + yard:doctest) in one shot.
|
|
@@ -43,7 +42,7 @@ nix build .#errgonomic # the gem builds as a derivation; rake runs in
|
|
|
43
42
|
nix flake check --all-systems # builds every check on the local system, evaluates all four
|
|
44
43
|
```
|
|
45
44
|
|
|
46
|
-
**After push (CI gate):** a push is done when CI is green, not when `git push` succeeds. Check whatever CI this repo runs (`gh run list`, `gh run view --log-failed`); checks take minutes, so it is fine to schedule the check as a followup and keep working — but the change is not landed until they pass. A CI failure is a regression: diagnose it from the logs, reproduce it locally where you can, and capture it as a test so it cannot recur silently. CI earns you the coverage you cannot run locally — a target your machine isn't, a matrix leg, a slower suite — for free. Here, CI (`.github/workflows/main.yml`) runs `yard doctest
|
|
45
|
+
**After push (CI gate):** a push is done when CI is green, not when `git push` succeeds. Check whatever CI this repo runs (`gh run list`, `gh run view --log-failed`); checks take minutes, so it is fine to schedule the check as a followup and keep working — but the change is not landed until they pass. A CI failure is a regression: diagnose it from the logs, reproduce it locally where you can, and capture it as a test so it cannot recur silently. CI earns you the coverage you cannot run locally — a target your machine isn't, a matrix leg, a slower suite — for free. Here, CI (`.github/workflows/main.yml`) runs `yard doctest` and `rake test` on the latest Ruby 3.4.x on ubuntu-latest, matching the Ruby pinned by the flake.
|
|
47
46
|
|
|
48
47
|
Tests *are* the requirements: a behavior is defined by the test that asserts it. Prefer doctests where an example clarifies a function's contract — they document and test at once and cannot drift out of date without failing the build. In this repo, the YARD `@example` blocks under `lib/**/*.rb` are the primary suite.
|
|
49
48
|
|
data/README.md
CHANGED
|
@@ -111,17 +111,21 @@ Some(1).each.to_a # => [1]
|
|
|
111
111
|
|
|
112
112
|
`map` wraps whatever the block returns, as Rust's does, so a block that itself returns an Option gives `Some(Some(x))`. `and_then` is the spelling for that block.
|
|
113
113
|
|
|
114
|
-
Options
|
|
114
|
+
Options pattern match in the Rust shape. `Some`, `None`, `Ok` and `Err` name the variants in a pattern as well as building them, so a pattern reads as it does in Rust:
|
|
115
115
|
|
|
116
116
|
```ruby
|
|
117
117
|
case measurement
|
|
118
|
-
in
|
|
118
|
+
in Some(value)
|
|
119
119
|
"Measurement is #{value}"
|
|
120
|
-
in
|
|
120
|
+
in None
|
|
121
121
|
"Measurement is not available"
|
|
122
122
|
end
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
Leave the `else` off: the two branches cover an Option, and a value that is not one raises `NoMatchingPatternError` where an `else` would take it quietly. When that value is a Result, the error's message refuses to print; see the `case/in` note below. `deconstruct` answers `[value]` for a `Some` and `[]` for a `None`, the one-payload shape `Data.define(:value)` and Rust's tuple variants share, and there is no `deconstruct_keys`, because a one-payload sum type has no named field. Patterns nest through the inner value's own protocol instead: `in Ok(Some(value))` reaches through a Result, and `in Some({ id: })` reaches into a Hash a `Some` wraps.
|
|
126
|
+
|
|
127
|
+
A bare `Some`, `None`, `Ok` or `Err`, without parentheses, is that name and not a value, where Rust's `return None` is one. It matches as the class it names in a pattern or a `case/when`, and refuses to stand in for a value: interpolation, `to_s`, `join`, `to_json` and `as_json` raise `Errgonomic::SerializeError` (`bare None names a variant for a pattern, not a value; build one with parentheses`), and so does handing one to an ActiveRecord attribute, a bulk write or a `where`. It is not a class, so `is_a?`, `kind_of?` and `instance_of?` raise `TypeError` when given the bare name (`class or module required`), `Some.new`, `Some <= Errgonomic::Option::Any` and `Some.name` raise `NoMethodError`, and Minitest's `assert_kind_of Some, x` raises the same `TypeError` before it can assert anything. Each has a working spelling: check the variant with `x.some?`, `x.none?`, `x.ok?` or `x.err?`, or give the fully qualified class anywhere a class or module is required, as in `x.is_a?(Errgonomic::Option::Some)`, `assert_kind_of Errgonomic::Option::Some, x`, `Errgonomic::Option::Some.new(1)`, `Errgonomic::Option::Some <= Errgonomic::Option::Any` or `Errgonomic::Option::Some.name`. Build with the constructor, `Some(1)`, rather than `.new`. An application constant of the same name collides loudly: defined before errgonomic loads, it stops the load with a `NameError`, and a `class None` or `module Ok` written after raises `TypeError` where it is written. Two forms get past that. `None = …` assigned after the load replaces the name with only Ruby's `already initialized constant` warning, and in a Rails application Zeitwerk skips an autoloaded `app/models/ok.rb` because `Ok` is already defined, so the first call on `Ok` raises `NoMethodError`.
|
|
128
|
+
|
|
125
129
|
An unhandled Option refuses to leak into your output: `to_s`, `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `Some("...")` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration).
|
|
126
130
|
|
|
127
131
|
The `to_s` refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, `Array#join`, `format`, `String()`, a bare ERB `<%= %>` and the key of a Hash on its way to JSON all reach the value through `to_s`, and every one of them raises rather than writing `Some("...")` into a hostname, a column or a page. Rust gives `Option` a `Debug` and no `Display`, and `inspect` is the `Debug` here: it renders `Some(1)`, and it is what a log line or a `rescue` should call (`"got #{opt.inspect}"`). The refusal says so: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`.
|
|
@@ -146,7 +150,7 @@ The remaining present-side helpers are soft-deprecated on Options in favor of th
|
|
|
146
150
|
|
|
147
151
|
Four of Rust's methods are deliberately absent: `take`, `replace`, `insert` and `get_or_insert`. Every one of them writes through an `&mut Option`, and an Option here is a value rather than a slot: `Some(1)` is something you pass around and compare, not a cell whose contents you swap out from under another reference. Build the Option you want and assign it where the old one lived. The same rule is why a `Some`, an `Ok` and an `Err` have no `value` reader or writer, and why every instance is frozen as it is constructed. A copy that skips construction, from `dup`, `Marshal.load`, a YAML load or ActiveSupport's `deep_dup`, is not frozen; with no writer, it changes only through `instance_variable_set`. A reader would reach the inner value with no `None` branch, and a writer would move a Hash key out from under its own bucket. Reach in with `unwrap_or`, `expect!`, `map`, `and_then` or a pattern, each of which names what happens on the other branch.
|
|
148
152
|
|
|
149
|
-
Equality is between Options only: `Some(5) == Some(5)
|
|
153
|
+
Equality is between Options only: `Some(5) == Some(5)` is `true` and `Some(5) == Some(6)` is `false`, and comparing an Option with anything that is not one raises `Errgonomic::TypeMismatchError`. Rust rejects `Some(5) == 5` at compile time; Ruby cannot, and a quiet `false` there is a silent wrong branch, the same failure as a wrapper written into a string. Compare against a wrapped value (`opt == Some(5)`), test the inner value (`opt.some_and? { |v| v == 5 }`), or `unwrap_or` a fallback first. `None() == nil` raises too, pointing at `none?`. See [Strict equality](#strict-equality) for where the raise reaches and where it cannot.
|
|
150
154
|
|
|
151
155
|
Ordering is between Options too, and unlike equality it says so out loud. `None()` sorts before any `Some` and two `Some`s order by their inner values, so a collection of Options sorts. Ordering one against a bare value raises `Errgonomic::TypeMismatchError` naming both operands and the spellings that work: `Some(read_at) <= Time.current` used to answer `nil` from `<=>`, which `Comparable` turned into an `ArgumentError` naming the Option as the operand at fault. Test the inner value (`read_at.some_and? { |t| t <= Time.current }`) or reach for it with `map` or `unwrap_or`. Two Options whose inner values do not compare still answer `nil`, as Ruby expects. That message is the gem's only where the Option is the receiver: with it on the right (`2 < Some(1)`, `[Some(1), 2].max`) `Integer` answers the comparison itself and Ruby raises its own `ArgumentError: comparison of Integer with Errgonomic::Option::Some failed`. Results order the same way, with `ok_and?` in place of `some_and?`.
|
|
152
156
|
|
|
@@ -172,11 +176,11 @@ Results also pattern match, including against the kind of inner value:
|
|
|
172
176
|
|
|
173
177
|
```ruby
|
|
174
178
|
case result
|
|
175
|
-
in
|
|
179
|
+
in Ok(value)
|
|
176
180
|
"Measurement is #{value}"
|
|
177
|
-
in
|
|
181
|
+
in Err(String => msg)
|
|
178
182
|
"Measurement failed with a message: #{msg}"
|
|
179
|
-
in
|
|
183
|
+
in Err(Exception => e)
|
|
180
184
|
"Measurement produced an exception -- #{e.class}: #{e}"
|
|
181
185
|
end
|
|
182
186
|
```
|
|
@@ -253,35 +257,37 @@ Errgonomic.with_ambiguous_downstream_errors do
|
|
|
253
257
|
end
|
|
254
258
|
```
|
|
255
259
|
|
|
256
|
-
|
|
260
|
+
### Strict equality
|
|
257
261
|
|
|
258
|
-
|
|
259
|
-
Errgonomic.strict_equality = true
|
|
262
|
+
Cross-type equality is the other pedantic check, and there is no switch for it. A comparison between a wrapper and a value that is not one raises `Errgonomic::TypeMismatchError`, naming both classes and the spelling to reach for:
|
|
260
263
|
|
|
264
|
+
```ruby
|
|
261
265
|
Some(5) == 5 # => raises Errgonomic::TypeMismatchError
|
|
262
266
|
Some(5) != 5 # => raises
|
|
263
267
|
Some(5).eql?(5) # => raises
|
|
268
|
+
Some(5) === 5 # => raises, so `case 5 when Some(5)` raises too
|
|
264
269
|
None() == nil # => raises, pointing at none?
|
|
265
270
|
Ok(1) == 1 # => raises
|
|
266
271
|
Some(1) == Ok(1) # => raises: an Option and a Result are different containers
|
|
267
|
-
Some(5) == Some(5) # => true
|
|
272
|
+
Some(5) == Some(5) # => true
|
|
273
|
+
Some(5) == None() # => false
|
|
268
274
|
|
|
269
275
|
1 == Some(1) # => raises, through Integer's coercion fallback
|
|
270
276
|
nil == None() # => false, quietly
|
|
271
277
|
"a" == Some("a") # => false, quietly
|
|
272
278
|
```
|
|
273
279
|
|
|
274
|
-
A Result is cross-type for an Option and an Option is cross-type for a Result: they are different containers, neither is the other, and the message says to unwrap whichever one you meant. Two Options, or two Results, compare
|
|
280
|
+
A Result is cross-type for an Option and an Option is cross-type for a Result: they are different containers, neither is the other, and the message says to unwrap whichever one you meant. Two Options, or two Results, compare by variant and inner value, and `hash` is untouched, so an Option is a Hash key like any other value.
|
|
275
281
|
|
|
276
|
-
|
|
282
|
+
The raise surface is uneven, because Ruby's collections reach equality three ways, and which side holds the wrapper decides what happens. The paragraph after this one gives the rule for `==` itself.
|
|
277
283
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
284
|
+
- Pairwise `==`: `Array#include?`, `Array#index`, `Array#delete`, `Array#count`, `Array#==`, `Hash#==`, `case/when` and Minitest's `assert_equal` raise when the wrapper is the one asked: a member of the collection searched (`[Some(1)].include?(1)`), a `when` clause, or `assert_equal`'s expected value, which comes back as an error rather than a failure. With the wrapper on the other side the bare value answers. An Integer or another Numeric hands the comparison back, so `[1].include?(Some(1))` raises too; a String, a Symbol or `nil` answers `false` itself, so `["a"].include?(Some("a"))` and `case Some("a") when "a"` stay quiet, and `assert_equal "a", Some("a")` is an ordinary failure.
|
|
285
|
+
- Pairwise `eql?`: `Array#-`, `Array#&` and `Array#|` compare member by member with `eql?` while the arrays are short, and a bare value's `eql?` never hands the comparison back. `[Some(1)] - [1]`, `[Some(1)] & [1]` and `[1] | [Some(1)]` raise; `[1] - [Some(1)]`, `[1] & [Some(1)]` and `[Some(1)] | [1]` stay quiet. Past Ruby's cutoff of 16 members they hash instead and stay quiet: `-` once both arrays are past it, `&` and `|` once either is.
|
|
286
|
+
- Hashing: `Hash#[]`, `Set#include?`, `uniq` and `group_by` compare hash values first and ask `eql?` only of a candidate whose hash matches, so they stay quiet whichever side holds the wrapper.
|
|
287
|
+
|
|
288
|
+
Strict equality never answers wrong; it only sometimes fails to catch.
|
|
283
289
|
|
|
284
|
-
|
|
290
|
+
Strictness fires when the wrapper is the receiver, and also when the left operand hands the comparison over: `1 == Some(1)` raises because `Integer#==` falls back to asking the right-hand side. `nil == Some(1)`, `nil == None()` and `"a" == Some("a")` stay quietly false, because `NilClass` and `String` answer for themselves and never consult the operand, and nothing in the gem can intercept them. Put the wrapper on the left in a test if you want the check to reach every comparison.
|
|
285
291
|
|
|
286
292
|
### Rails integration
|
|
287
293
|
|
|
@@ -409,7 +415,7 @@ It is available on every model, converted or not, because it lifts both ends one
|
|
|
409
415
|
|
|
410
416
|
This is the register of where the gem leaves the Rust idiom, and why. ActiveRecord assumes things about accessors that a strict Rust `Option` cannot satisfy, so the integration carries five deliberate compromises, each one forced by a specific piece of ActiveRecord machinery rather than chosen. Everywhere else, treat a departure from Rust's `Option` semantics as a bug; these five are intended:
|
|
411
417
|
|
|
412
|
-
1. `None#nil?` answers `true`, so ActiveRecord internals and ordinary `.nil?` checks treat an absent value as absent. Equality does not follow suit: `None() == nil` is
|
|
418
|
+
1. `None#nil?` answers `true`, so ActiveRecord internals and ordinary `.nil?` checks treat an absent value as absent. Equality does not follow suit: `None() == nil` raises `Errgonomic::TypeMismatchError`, pointing at `none?`, and `nil == None()` is `false`, answered by `NilClass`. Nor does `Array#compact`, the common collection idiom for dropping absent members: it tests for the `nil` object, so it keeps a `None` where `reject(&:none?)` drops it.
|
|
413
419
|
2. `Some` delegates `persisted?` and `touch_later` to its record, so a `Some` can stand in for it where ActiveRecord reads an association back through its public reader, as a `belongs_to ..., touch: true` does after a save.
|
|
414
420
|
3. An `Option` is unwrapped where a value enters ActiveRecord, above the column type in every case. Quoting and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should. Assignment unwraps on the same principle. A singular association writer takes an Option of a record: `book.author = Some(author)` assigns it and `book.author = None()` clears the association, while a `Some` of the wrong class still raises `AssociationTypeMismatch`. An attribute writer takes an Option of a value, for every column type, and unwraps before the attribute is built, so `book.isbn = other.isbn` round-trips and nothing behind the reader ever holds a wrapper. A value that reaches the database without passing a writer unwraps where it enters ActiveRecord, above the column type in every case: in the ids and conditions `find` and `find_by` are given, on a class, a relation and an association alike; in the rows `update_all`, `insert_all` and `upsert` take; and in a default declared with `attribute :isbn, :string, default: Some('unassigned')`, unwrapped where it is written.
|
|
415
421
|
4. `SomeValidator` asks whether a value is there at all, where `presence` asks whether it amounts to anything: `Some('')` passes `validates :x, some: true` and fails `presence: true`. It lifts what it is handed, so it asks the same question of any model, converted or not.
|
data/Rakefile
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'bundler/gem_tasks'
|
|
4
|
-
require 'shellwords'
|
|
5
4
|
|
|
6
5
|
require 'rake/testtask'
|
|
7
6
|
Rake::TestTask.new(:test) do |t|
|
|
@@ -17,16 +16,9 @@ YARD::Doctest::RakeTask.new do |task|
|
|
|
17
16
|
task.pattern = FileList['lib/**/*.rb'].join(' ')
|
|
18
17
|
end
|
|
19
18
|
|
|
20
|
-
namespace :test do
|
|
21
|
-
desc 'Run the Rails integration suite with strict equality on'
|
|
22
|
-
task :strict do
|
|
23
|
-
ruby '-Ilib', '-Itest', 'test/support/strict_equality.rb', *Shellwords.split(ENV.fetch('TESTOPTS', ''))
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
19
|
# yard:doctest ends the process when it finishes, so anything after it in
|
|
28
20
|
# the default list would never run.
|
|
29
|
-
task default: %i[test
|
|
21
|
+
task default: %i[test yard:doctest]
|
|
30
22
|
|
|
31
23
|
namespace :gems4nix do
|
|
32
24
|
desc 'Regenerate gem-groups.json after Gemfile/Gemfile.lock changes'
|
data/lib/errgonomic/option.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require 'stringio'
|
|
5
|
+
require_relative 'variant_name'
|
|
5
6
|
|
|
6
7
|
module Errgonomic
|
|
7
8
|
module Option
|
|
@@ -65,61 +66,84 @@ module Errgonomic
|
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
# An Option equals another Option of the same class with an equal inner
|
|
68
|
-
# value.
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
69
|
+
# value. Comparing it with anything that is not an Option raises
|
|
70
|
+
# Errgonomic::TypeMismatchError, naming both sides and the spelling to
|
|
71
|
+
# reach for. Some(5) == 5 is the comparison Rust rejects at compile
|
|
72
|
+
# time, and a quiet false there is a silent wrong branch, the same
|
|
73
|
+
# failure as a wrapper written into a string. The raise reaches ==, !=,
|
|
74
|
+
# eql? and ===, and through them every collection operation that
|
|
75
|
+
# compares pairwise. Ruby's hashing compares hash values first and asks
|
|
76
|
+
# eql? only of a candidate whose hash matches, so a Hash lookup, a Set
|
|
77
|
+
# and uniq stay quiet with a wrong-typed key: strict equality never
|
|
78
|
+
# answers wrong, it only sometimes fails to catch. nil == Some(1) is
|
|
79
|
+
# answered by NilClass and cannot be intercepted.
|
|
74
80
|
#
|
|
75
|
-
# None() == nil
|
|
76
|
-
#
|
|
77
|
-
# separately makes None#nil? answer true, as an
|
|
78
|
-
# compromise; equality does not follow it.)
|
|
81
|
+
# None() == nil raises too: None is a value that represents absence,
|
|
82
|
+
# not an absence Ruby can see, and the message points at none?. (The
|
|
83
|
+
# Rails integration separately makes None#nil? answer true, as an
|
|
84
|
+
# ActiveRecord compromise; equality does not follow it.)
|
|
79
85
|
#
|
|
80
86
|
# @example
|
|
81
87
|
# Some(1) == Some(1) # => true
|
|
82
88
|
# Some(1) == Some(2) # => false
|
|
83
89
|
# Some(1) == None() # => false
|
|
84
90
|
# None() == None() # => true
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
# Errgonomic.
|
|
90
|
-
#
|
|
91
|
-
#
|
|
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
|
|
91
|
+
#
|
|
92
|
+
# @example a cross-type comparison is an error, never a quiet false
|
|
93
|
+
# Some(5) == 5 # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some == Integer, which strict equality refuses.\nCompare Options (opt == Some(5)), test the inner value (opt.some_and? { |v| v == 5 }), or unwrap_or a fallback first."
|
|
94
|
+
# Some(5) != 5 # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some != Integer, which strict equality refuses.\nCompare Options (opt == Some(5)), test the inner value (opt.some_and? { |v| v == 5 }), or unwrap_or a fallback first."
|
|
95
|
+
# Some(5) === 5 # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some === Integer, which strict equality refuses.\nCompare Options (opt == Some(5)), test the inner value (opt.some_and? { |v| v == 5 }), or unwrap_or a fallback first."
|
|
96
|
+
# Some(5) === Some(5) # => true
|
|
97
|
+
# 1 == Some(1) # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some == Integer, which strict equality refuses.\nCompare Options (opt == Some(1)), test the inner value (opt.some_and? { |v| v == 1 }), or unwrap_or a fallback first."
|
|
105
98
|
#
|
|
106
99
|
# @example a Result is another container, not another Option
|
|
107
|
-
# Errgonomic.
|
|
108
|
-
# begin
|
|
109
|
-
# Some(1) == Ok(1)
|
|
110
|
-
# rescue Errgonomic::TypeMismatchError => e
|
|
111
|
-
# e.message.include?("different containers")
|
|
112
|
-
# end
|
|
113
|
-
# end # => true
|
|
100
|
+
# Some(1) == Ok(1) # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some == Errgonomic::Result::Ok, which strict equality refuses.\nAn Option and a Result are different containers, and neither is the other. Unwrap the one you meant (opt.unwrap_or(nil) == res.unwrap_or(nil))."
|
|
114
101
|
#
|
|
115
102
|
# @example nil is another type, and absence here is the discriminant
|
|
116
|
-
# Errgonomic.
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
103
|
+
# None() == nil # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::None == NilClass, which strict equality refuses.\nAbsence here is the discriminant: ask none?, or nil? under the Rails integration."
|
|
104
|
+
#
|
|
105
|
+
# @example the raise reaches every operation that compares pairwise
|
|
106
|
+
# begin
|
|
107
|
+
# [Some(1)].include?(1)
|
|
108
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
109
|
+
# e.class
|
|
110
|
+
# end # => Errgonomic::TypeMismatchError
|
|
111
|
+
# begin
|
|
112
|
+
# [Some(1)] == [1]
|
|
113
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
114
|
+
# e.class
|
|
115
|
+
# end # => Errgonomic::TypeMismatchError
|
|
116
|
+
# begin
|
|
117
|
+
# [Some(1), 1] - [1]
|
|
118
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
119
|
+
# e.class
|
|
120
|
+
# end # => Errgonomic::TypeMismatchError
|
|
121
|
+
# begin
|
|
122
|
+
# { a: Some(1) } == { a: 1 }
|
|
123
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
124
|
+
# e.class
|
|
125
|
+
# end # => Errgonomic::TypeMismatchError
|
|
126
|
+
# begin
|
|
127
|
+
# case 5
|
|
128
|
+
# when Some(5) then :hit
|
|
121
129
|
# end
|
|
122
|
-
#
|
|
130
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
131
|
+
# e.class
|
|
132
|
+
# end # => Errgonomic::TypeMismatchError
|
|
133
|
+
#
|
|
134
|
+
# @example hashing compares hash values first, so these stay quiet
|
|
135
|
+
# { Some(1) => :v }[1] # => nil
|
|
136
|
+
# Set[Some(1)].include?(1) # => false
|
|
137
|
+
# [Some(1), 1].uniq # => [Some(1), 1]
|
|
138
|
+
#
|
|
139
|
+
# @example a short array compares member by member with eql?, so the side the wrapper is on decides
|
|
140
|
+
# [1] - [Some(1)] # => [1]
|
|
141
|
+
# [Some(1)] | [1] # => [Some(1), 1]
|
|
142
|
+
# [1] | [Some(1)] # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some eql? Integer, which strict equality refuses.\nCompare Options (opt == Some(1)), test the inner value (opt.some_and? { |v| v == 1 }), or unwrap_or a fallback first."
|
|
143
|
+
#
|
|
144
|
+
# @example nil and String answer for themselves, and never ask the Option
|
|
145
|
+
# nil == Some(1) # => false
|
|
146
|
+
# "a" == Some("a") # => false
|
|
123
147
|
def ==(other)
|
|
124
148
|
strict_equality!(other, '==')
|
|
125
149
|
return false if self.class != other.class
|
|
@@ -128,6 +152,13 @@ module Errgonomic
|
|
|
128
152
|
value == other.value
|
|
129
153
|
end
|
|
130
154
|
|
|
155
|
+
# Object#=== is ==, so a `case value when Some(5)` and a pinned pattern
|
|
156
|
+
# reach the same check, named for the operator that was written.
|
|
157
|
+
def ===(other)
|
|
158
|
+
strict_equality!(other, '===')
|
|
159
|
+
self == other
|
|
160
|
+
end
|
|
161
|
+
|
|
131
162
|
# Hash-based collections (Hash keys, Set, uniq, group_by) use eql? and
|
|
132
163
|
# hash, not ==. Follow the inner value's own eql? semantics, so Options
|
|
133
164
|
# behave as keys exactly like their inner values: Some(1) and Some(1.0)
|
|
@@ -140,22 +171,9 @@ module Errgonomic
|
|
|
140
171
|
# { Some(5) => 1 }[Some(5)] # => 1
|
|
141
172
|
# [Some(1), Some(1), None(), None()].uniq # => [Some(1), None()]
|
|
142
173
|
#
|
|
143
|
-
# @example
|
|
144
|
-
# Errgonomic.
|
|
145
|
-
#
|
|
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
|
-
|
|
174
|
+
# @example a cross-type eql? raises as == does, and hash is untouched
|
|
175
|
+
# Some(5).eql?(5) # => raise Errgonomic::TypeMismatchError, "Errgonomic::Option::Some eql? Integer, which strict equality refuses.\nCompare Options (opt == Some(5)), test the inner value (opt.some_and? { |v| v == 5 }), or unwrap_or a fallback first."
|
|
176
|
+
# Some(5).hash == Some(5).hash # => true
|
|
159
177
|
def eql?(other)
|
|
160
178
|
strict_equality!(other, 'eql?')
|
|
161
179
|
return false if self.class != other.class
|
|
@@ -164,6 +182,13 @@ module Errgonomic
|
|
|
164
182
|
value.eql?(other.value)
|
|
165
183
|
end
|
|
166
184
|
|
|
185
|
+
# Ruby derives != from ==, so a strict-equality message would name the
|
|
186
|
+
# operator the caller did not write.
|
|
187
|
+
def !=(other)
|
|
188
|
+
strict_equality!(other, '!=')
|
|
189
|
+
super
|
|
190
|
+
end
|
|
191
|
+
|
|
167
192
|
# @example
|
|
168
193
|
# Some(5).hash == Some(5).hash # => true
|
|
169
194
|
# None().hash == None().hash # => true
|
|
@@ -174,20 +199,65 @@ module Errgonomic
|
|
|
174
199
|
[self.class, value].hash
|
|
175
200
|
end
|
|
176
201
|
|
|
202
|
+
# The Rust shape: a Some deconstructs to its one payload and a None to
|
|
203
|
+
# nothing, so `in Some(v)` binds the value and `in None` matches. There
|
|
204
|
+
# is no deconstruct_keys, because a one-payload sum type has no named
|
|
205
|
+
# field; a Some wrapping a Hash nests as `in Some({id:})` through the
|
|
206
|
+
# Hash's own protocol.
|
|
207
|
+
#
|
|
177
208
|
# @example
|
|
178
|
-
#
|
|
209
|
+
# Some(1).deconstruct # => [1]
|
|
210
|
+
# None().deconstruct # => []
|
|
211
|
+
# Some(1).respond_to?(:deconstruct_keys) # => false
|
|
212
|
+
#
|
|
213
|
+
# @example a two-branch case/in with no else is exhaustive
|
|
214
|
+
# measurement = Some(1)
|
|
179
215
|
# case measurement
|
|
180
|
-
# in
|
|
216
|
+
# in Some(value)
|
|
181
217
|
# "Measurement is #{value}"
|
|
182
|
-
# in
|
|
218
|
+
# in None
|
|
183
219
|
# "Measurement is not available"
|
|
184
|
-
# else
|
|
185
|
-
# "not matched"
|
|
186
220
|
# end # => "Measurement is 1"
|
|
221
|
+
# case None()
|
|
222
|
+
# in Some(value)
|
|
223
|
+
# "Measurement is #{value}"
|
|
224
|
+
# in None
|
|
225
|
+
# "Measurement is not available"
|
|
226
|
+
# end # => "Measurement is not available"
|
|
227
|
+
#
|
|
228
|
+
# @example the wrong type falls through to Ruby's own exhaustiveness check
|
|
229
|
+
# begin
|
|
230
|
+
# case 1
|
|
231
|
+
# in Some(value) then value
|
|
232
|
+
# in None then nil
|
|
233
|
+
# end
|
|
234
|
+
# rescue NoMatchingPatternError => e
|
|
235
|
+
# [e.class, e.message]
|
|
236
|
+
# end # => [NoMatchingPatternError, "1"]
|
|
237
|
+
#
|
|
238
|
+
# @example a Result that falls through carries a message that refuses to print
|
|
239
|
+
# begin
|
|
240
|
+
# case Ok(1)
|
|
241
|
+
# in Some(value) then value
|
|
242
|
+
# in None then nil
|
|
243
|
+
# end
|
|
244
|
+
# rescue NoMatchingPatternError => e
|
|
245
|
+
# [e.class, (e.message rescue $!.class)]
|
|
246
|
+
# end # => [NoMatchingPatternError, Errgonomic::SerializeError]
|
|
247
|
+
#
|
|
248
|
+
# @example patterns nest through the inner value's own protocol
|
|
249
|
+
# case Ok(Some(1))
|
|
250
|
+
# in Ok(Some(value)) then value
|
|
251
|
+
# end # => 1
|
|
252
|
+
# case Some({ id: 7, name: 'x' })
|
|
253
|
+
# in Some({ id: }) then id
|
|
254
|
+
# end # => 7
|
|
255
|
+
# case Some(1)
|
|
256
|
+
# in Errgonomic::Option::Some(value) then "bound #{value}"
|
|
257
|
+
# else "not matched"
|
|
258
|
+
# end # => "bound 1"
|
|
187
259
|
def deconstruct
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
[Errgonomic::Option::None]
|
|
260
|
+
to_a
|
|
191
261
|
end
|
|
192
262
|
|
|
193
263
|
# Options order like Rust's: None sorts before any Some, and Somes
|
|
@@ -820,7 +890,6 @@ module Errgonomic
|
|
|
820
890
|
end
|
|
821
891
|
|
|
822
892
|
def strict_equality!(other, operator)
|
|
823
|
-
return unless Errgonomic.strict_equality?
|
|
824
893
|
return if other.is_a?(Errgonomic::Option::Any)
|
|
825
894
|
|
|
826
895
|
raise Errgonomic::TypeMismatchError,
|
|
@@ -961,3 +1030,8 @@ end
|
|
|
961
1030
|
def None
|
|
962
1031
|
Errgonomic::Option::None.new
|
|
963
1032
|
end
|
|
1033
|
+
|
|
1034
|
+
# The variants under their short names, so a pattern reads as it does in
|
|
1035
|
+
# Rust: `in Some(v)`, `in None`.
|
|
1036
|
+
Errgonomic::VariantName.define(:Some, Errgonomic::Option::Some)
|
|
1037
|
+
Errgonomic::VariantName.define(:None, Errgonomic::Option::None)
|
|
@@ -18,9 +18,10 @@ module Errgonomic
|
|
|
18
18
|
#
|
|
19
19
|
# 1. None#nil? answers true, so AR internals and ordinary nil checks
|
|
20
20
|
# treat an absent value as absent. Equality does not follow suit:
|
|
21
|
-
# None() == nil
|
|
22
|
-
# collection idiom for dropping absent
|
|
23
|
-
# nil object, so it keeps a None where
|
|
21
|
+
# None() == nil raises, as any cross-type comparison does. Nor does
|
|
22
|
+
# Array#compact, the common collection idiom for dropping absent
|
|
23
|
+
# members: it tests for the nil object, so it keeps a None where
|
|
24
|
+
# reject(&:none?) drops it.
|
|
24
25
|
# 2. Some delegates persisted? and touch_later to its record, so a Some
|
|
25
26
|
# can stand in for it where ActiveRecord reads an association back
|
|
26
27
|
# through its public reader.
|
|
@@ -516,10 +517,10 @@ module Errgonomic
|
|
|
516
517
|
# Errgonomic::Rails.unwrap_options(1) # => 1
|
|
517
518
|
def self.unwrap_options(value)
|
|
518
519
|
case value
|
|
519
|
-
when Errgonomic::Option::Any
|
|
520
|
-
value
|
|
520
|
+
when Errgonomic::Option::Any, Errgonomic::VariantName
|
|
521
|
+
unwrap_option(value)
|
|
521
522
|
when Array
|
|
522
|
-
value.any? { |v|
|
|
523
|
+
value.any? { |v| unwrapped_at_boundary?(v) } ? value.map { |v| unwrap_options(v) } : value
|
|
523
524
|
else
|
|
524
525
|
value
|
|
525
526
|
end
|
|
@@ -527,13 +528,16 @@ module Errgonomic
|
|
|
527
528
|
|
|
528
529
|
# Take the value inside an Option, and a None as nil, where the boundary
|
|
529
530
|
# takes one value: an attribute is a single typed field, so a collection
|
|
530
|
-
# that happens to hold an Option is that collection.
|
|
531
|
+
# that happens to hold an Option is that collection. A bare variant name
|
|
532
|
+
# is refused here, since a boolean column would cast it to true.
|
|
531
533
|
#
|
|
532
534
|
# @example
|
|
533
535
|
# Errgonomic::Rails.unwrap_option(Some(1)) # => 1
|
|
534
536
|
# Errgonomic::Rails.unwrap_option(None()) # => nil
|
|
535
537
|
# Errgonomic::Rails.unwrap_option([Some(1)]) # => [Some(1)]
|
|
538
|
+
# Errgonomic::Rails.unwrap_option(None) # => raise Errgonomic::SerializeError, "bare None names a variant for a pattern, not a value; build one with parentheses"
|
|
536
539
|
def self.unwrap_option(value)
|
|
540
|
+
value.refuse! if value.is_a?(Errgonomic::VariantName)
|
|
537
541
|
value.is_a?(Errgonomic::Option::Any) ? value.unwrap_or(nil) : value
|
|
538
542
|
end
|
|
539
543
|
|
|
@@ -548,7 +552,7 @@ module Errgonomic
|
|
|
548
552
|
# plain = { title: 'x' }
|
|
549
553
|
# Errgonomic::Rails.unwrap_option_values(plain).equal?(plain) # => true
|
|
550
554
|
def self.unwrap_option_values(hash)
|
|
551
|
-
return hash unless hash.each_value.any?(
|
|
555
|
+
return hash unless hash.each_value.any? { |value| unwrapped_at_boundary?(value) }
|
|
552
556
|
|
|
553
557
|
hash.transform_values { |value| unwrap_option(value) }
|
|
554
558
|
end
|
|
@@ -561,11 +565,17 @@ module Errgonomic
|
|
|
561
565
|
# plain = [{ title: 'x' }]
|
|
562
566
|
# Errgonomic::Rails.unwrap_option_rows(plain).equal?(plain) # => true
|
|
563
567
|
def self.unwrap_option_rows(rows)
|
|
564
|
-
return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any?(
|
|
568
|
+
return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any? { |v| unwrapped_at_boundary?(v) } }
|
|
565
569
|
|
|
566
570
|
rows.map { |row| row.is_a?(Hash) ? unwrap_option_values(row) : row }
|
|
567
571
|
end
|
|
568
572
|
|
|
573
|
+
# An Option, or a bare variant name, which a boundary refuses rather than
|
|
574
|
+
# let a column type cast it.
|
|
575
|
+
def self.unwrapped_at_boundary?(value)
|
|
576
|
+
value.is_a?(Errgonomic::Option::Any) || value.is_a?(Errgonomic::VariantName)
|
|
577
|
+
end
|
|
578
|
+
|
|
569
579
|
# A declared default that is a Proc is not a value yet: ActiveModel calls
|
|
570
580
|
# it with no arguments each time a record is built. Wrap it rather than
|
|
571
581
|
# unwrap it, so what it returns meets the type where a literal default
|
data/lib/errgonomic/result.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'variant_name'
|
|
4
|
+
|
|
3
5
|
module Errgonomic
|
|
4
6
|
module Result
|
|
5
7
|
# The base class for Result's Ok and Err class variants. We implement as
|
|
@@ -105,7 +107,11 @@ module Errgonomic
|
|
|
105
107
|
RUST_SPELLINGS.key?(name) || super
|
|
106
108
|
end
|
|
107
109
|
|
|
108
|
-
#
|
|
110
|
+
# A Result equals another Result of the same variant with an equal
|
|
111
|
+
# inner value. Comparing it with anything that is not a Result raises
|
|
112
|
+
# Errgonomic::TypeMismatchError, on the terms Option#== states: the
|
|
113
|
+
# raise reaches ==, !=, eql? and ===, hashing stays quiet, and a bare
|
|
114
|
+
# value on the left answers for itself.
|
|
109
115
|
#
|
|
110
116
|
# @param other [Object]
|
|
111
117
|
#
|
|
@@ -113,34 +119,23 @@ module Errgonomic
|
|
|
113
119
|
# Ok(1) == Ok(1) # => true
|
|
114
120
|
# Ok(1) == Err(1) # => false
|
|
115
121
|
# Ok(1).object_id == Ok(1).object_id # => false
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
# Errgonomic.
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
122
|
+
#
|
|
123
|
+
# @example a cross-type comparison is an error, never a quiet false
|
|
124
|
+
# Ok(1) == 1 # => raise Errgonomic::TypeMismatchError, "Errgonomic::Result::Ok == Integer, which strict equality refuses.\nCompare Results (res == Ok(1)), test the inner value (res.ok_and? { |v| v == 1 }), or unwrap_or a fallback first."
|
|
125
|
+
# Ok(1) != 1 # => raise Errgonomic::TypeMismatchError, "Errgonomic::Result::Ok != Integer, which strict equality refuses.\nCompare Results (res == Ok(1)), test the inner value (res.ok_and? { |v| v == 1 }), or unwrap_or a fallback first."
|
|
126
|
+
# Ok(1) === 1 # => raise Errgonomic::TypeMismatchError, "Errgonomic::Result::Ok === Integer, which strict equality refuses.\nCompare Results (res == Ok(1)), test the inner value (res.ok_and? { |v| v == 1 }), or unwrap_or a fallback first."
|
|
127
|
+
# Err() == nil # => raise Errgonomic::TypeMismatchError, "Errgonomic::Result::Err == NilClass, which strict equality refuses.\nCompare Results (res == Ok(nil)), test the inner value (res.ok_and? { |v| v == nil }), or unwrap_or a fallback first."
|
|
128
|
+
# Ok(1) === Ok(1) # => true
|
|
129
|
+
# begin
|
|
130
|
+
# [Ok(1)].include?(1)
|
|
131
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
132
|
+
# e.class
|
|
126
133
|
# end # => Errgonomic::TypeMismatchError
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
# begin
|
|
130
|
-
# Ok(1) != 1
|
|
131
|
-
# rescue Errgonomic::TypeMismatchError => e
|
|
132
|
-
# e.message.include?("!=")
|
|
133
|
-
# end
|
|
134
|
-
# end # => true
|
|
134
|
+
# { Ok(1) => :v }[1] # => nil
|
|
135
|
+
# nil == Err() # => false
|
|
135
136
|
#
|
|
136
137
|
# @example an Option is another container, not another Result
|
|
137
|
-
# Errgonomic.
|
|
138
|
-
# begin
|
|
139
|
-
# Ok(1) == Some(1)
|
|
140
|
-
# rescue Errgonomic::TypeMismatchError => e
|
|
141
|
-
# e.message.include?("different containers")
|
|
142
|
-
# end
|
|
143
|
-
# end # => true
|
|
138
|
+
# Ok(1) == Some(1) # => raise Errgonomic::TypeMismatchError, "Errgonomic::Result::Ok == Errgonomic::Option::Some, which strict equality refuses.\nA Result and an Option are different containers, and neither is the other.\nUnwrap the one you meant (res.unwrap_or(nil) == opt.unwrap_or(nil))."
|
|
144
139
|
def ==(other)
|
|
145
140
|
strict_equality!(other, '==')
|
|
146
141
|
return false if self.class != other.class
|
|
@@ -148,6 +143,13 @@ module Errgonomic
|
|
|
148
143
|
value == other.value
|
|
149
144
|
end
|
|
150
145
|
|
|
146
|
+
# Object#=== is ==, so a `case value when Ok(1)` and a pinned pattern
|
|
147
|
+
# reach the same check, named for the operator that was written.
|
|
148
|
+
def ===(other)
|
|
149
|
+
strict_equality!(other, '===')
|
|
150
|
+
self == other
|
|
151
|
+
end
|
|
152
|
+
|
|
151
153
|
# Hash-based collections (Hash keys, Set, uniq, group_by) use eql? and
|
|
152
154
|
# hash, not ==. Follow the inner value's own eql? semantics, so Results
|
|
153
155
|
# behave as keys exactly like their inner values.
|
|
@@ -159,15 +161,14 @@ module Errgonomic
|
|
|
159
161
|
# { Ok(5) => 1 }[Ok(5)] # => 1
|
|
160
162
|
# [Err(:a), Err(:a)].uniq # => [Err(:a)]
|
|
161
163
|
#
|
|
162
|
-
# @example
|
|
163
|
-
# Errgonomic.
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
# Errgonomic.with_strict_equality { Ok(5).hash == Ok(5).hash } # => true
|
|
164
|
+
# @example a cross-type eql? raises as == does, and hash is untouched
|
|
165
|
+
# Ok(5).eql?(5) # => raise Errgonomic::TypeMismatchError, "Errgonomic::Result::Ok eql? Integer, which strict equality refuses.\nCompare Results (res == Ok(5)), test the inner value (res.ok_and? { |v| v == 5 }), or unwrap_or a fallback first."
|
|
166
|
+
# Ok(5).hash == Ok(5).hash # => true
|
|
167
|
+
def eql?(other)
|
|
168
|
+
strict_equality!(other, 'eql?')
|
|
169
|
+
self.class == other.class && value.eql?(other.value)
|
|
170
|
+
end
|
|
171
|
+
|
|
171
172
|
# Ruby derives != from ==, so a strict-equality message would name the
|
|
172
173
|
# operator the caller did not write.
|
|
173
174
|
def !=(other)
|
|
@@ -175,11 +176,6 @@ module Errgonomic
|
|
|
175
176
|
super
|
|
176
177
|
end
|
|
177
178
|
|
|
178
|
-
def eql?(other)
|
|
179
|
-
strict_equality!(other, 'eql?')
|
|
180
|
-
self.class == other.class && value.eql?(other.value)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
179
|
# @example
|
|
184
180
|
# Ok(5).hash == Ok(5).hash # => true
|
|
185
181
|
# Ok(5).hash == Err(5).hash # => false
|
|
@@ -471,27 +467,79 @@ module Errgonomic
|
|
|
471
467
|
pp.text(inspect)
|
|
472
468
|
end
|
|
473
469
|
|
|
474
|
-
#
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
#
|
|
470
|
+
# The Rust shape: each variant deconstructs to its one payload, so
|
|
471
|
+
# `in Ok(v)` binds the value and `in Err(e)` binds the error. A
|
|
472
|
+
# value-less Err deconstructs to nothing: the sentinel behind it is
|
|
473
|
+
# internal and must never bind to a pattern variable.
|
|
474
|
+
#
|
|
475
|
+
# @example
|
|
476
|
+
# Ok(1).deconstruct # => [1]
|
|
477
|
+
# Err(:e).deconstruct # => [:e]
|
|
478
|
+
# Err().deconstruct # => []
|
|
479
|
+
# Ok(1).respond_to?(:deconstruct_keys) # => false
|
|
480
|
+
#
|
|
481
|
+
# @example a value-less Err matches `in Err` and `in Err()`, never `in Err(e)`
|
|
482
|
+
# case Err()
|
|
483
|
+
# in Err(e) then e
|
|
484
|
+
# in Err then :no_value
|
|
485
|
+
# end # => :no_value
|
|
486
|
+
# case Err()
|
|
487
|
+
# in Err() then :no_value
|
|
488
|
+
# end # => :no_value
|
|
489
|
+
# case Err(:x)
|
|
490
|
+
# in Err(e) then e
|
|
491
|
+
# in Err then :no_value
|
|
492
|
+
# end # => :x
|
|
493
|
+
# begin
|
|
494
|
+
# case Err()
|
|
495
|
+
# in Err(e) then e
|
|
496
|
+
# end
|
|
497
|
+
# rescue NoMatchingPatternError => e
|
|
498
|
+
# e.class
|
|
499
|
+
# end # => NoMatchingPatternError
|
|
500
|
+
#
|
|
501
|
+
# @example a two-branch case/in with no else is exhaustive
|
|
502
|
+
# case Ok(1)
|
|
503
|
+
# in Ok(value)
|
|
478
504
|
# "Measurement is #{value}"
|
|
479
|
-
# in
|
|
505
|
+
# in Err(err)
|
|
480
506
|
# "Measurement is not available"
|
|
481
507
|
# end # => "Measurement is 1"
|
|
482
508
|
#
|
|
483
|
-
# @example
|
|
484
|
-
#
|
|
509
|
+
# @example the wrong type falls through to Ruby's own exhaustiveness check
|
|
510
|
+
# begin
|
|
511
|
+
# case :done
|
|
512
|
+
# in Ok(value) then value
|
|
513
|
+
# in Err(err) then err
|
|
514
|
+
# end
|
|
515
|
+
# rescue NoMatchingPatternError => e
|
|
516
|
+
# [e.class, e.message]
|
|
517
|
+
# end # => [NoMatchingPatternError, "done"]
|
|
518
|
+
#
|
|
519
|
+
# @example an Option that falls through carries a message that refuses to print
|
|
520
|
+
# begin
|
|
521
|
+
# case Some(1)
|
|
522
|
+
# in Ok(value) then value
|
|
523
|
+
# in Err(err) then err
|
|
524
|
+
# end
|
|
525
|
+
# rescue NoMatchingPatternError => e
|
|
526
|
+
# [e.class, (e.message rescue $!.class)]
|
|
527
|
+
# end # => [NoMatchingPatternError, Errgonomic::SerializeError]
|
|
528
|
+
#
|
|
529
|
+
# @example a pattern reaches the kind of value inside the variant
|
|
530
|
+
# result = Err(StandardError.new("nope"))
|
|
485
531
|
# case result
|
|
486
|
-
# in
|
|
532
|
+
# in Ok(value)
|
|
487
533
|
# "Measurement is #{value}"
|
|
488
|
-
# in
|
|
534
|
+
# in Err(String => msg)
|
|
489
535
|
# "Measurement failed with a message: #{msg}"
|
|
490
|
-
# in
|
|
536
|
+
# in Err(Exception => e)
|
|
491
537
|
# "Measurement produced an exception -- #{e.class}: #{e}"
|
|
492
538
|
# end # => "Measurement produced an exception -- StandardError: nope"
|
|
493
539
|
def deconstruct
|
|
494
|
-
[
|
|
540
|
+
return [] if value.equal?(Err::Arbitrary)
|
|
541
|
+
|
|
542
|
+
[value]
|
|
495
543
|
end
|
|
496
544
|
|
|
497
545
|
protected
|
|
@@ -514,7 +562,6 @@ module Errgonomic
|
|
|
514
562
|
end
|
|
515
563
|
|
|
516
564
|
def strict_equality!(other, operator)
|
|
517
|
-
return unless Errgonomic.strict_equality?
|
|
518
565
|
return if other.is_a?(Errgonomic::Result::Any)
|
|
519
566
|
|
|
520
567
|
raise Errgonomic::TypeMismatchError,
|
|
@@ -595,7 +642,7 @@ module Errgonomic
|
|
|
595
642
|
# @example
|
|
596
643
|
# Err(:nope).inspect # => "Err(:nope)"
|
|
597
644
|
# Err().inspect # => "Err()"
|
|
598
|
-
#
|
|
645
|
+
# Err(Some(1)).inspect # => "Err(Some(1))"
|
|
599
646
|
def inspect
|
|
600
647
|
return 'Err()' if value.equal?(Arbitrary)
|
|
601
648
|
|
|
@@ -643,3 +690,8 @@ end
|
|
|
643
690
|
def Err(value = Errgonomic::Result::Err::Arbitrary)
|
|
644
691
|
Errgonomic::Result::Err.new(value)
|
|
645
692
|
end
|
|
693
|
+
|
|
694
|
+
# The variants under their short names, so a pattern reads as it does in
|
|
695
|
+
# Rust: `in Ok(v)`, `in Err(e)`.
|
|
696
|
+
Errgonomic::VariantName.define(:Ok, Errgonomic::Result::Ok)
|
|
697
|
+
Errgonomic::VariantName.define(:Err, Errgonomic::Result::Err)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Errgonomic
|
|
4
|
+
# What a bare `Some`, `None`, `Ok` or `Err` evaluates to. Rust writes a
|
|
5
|
+
# value as `return None`, where here the value is `None()`; the bare name
|
|
6
|
+
# is for patterns. It matches as the class it names, and refuses to become
|
|
7
|
+
# a string, so a missing pair of parentheses raises rather than writing a
|
|
8
|
+
# class name into a column. It is not a class, so an application's own
|
|
9
|
+
# `class None` fails where it is written instead of reopening the gem's.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# case Some(1)
|
|
13
|
+
# in Some(value) then value
|
|
14
|
+
# end # => 1
|
|
15
|
+
# None === None() # => true
|
|
16
|
+
# None === Some(1) # => false
|
|
17
|
+
# None.inspect # => "Errgonomic::Option::None"
|
|
18
|
+
# "tier-#{None}" # => raise Errgonomic::SerializeError, "bare None names a variant for a pattern, not a value; build one with parentheses"
|
|
19
|
+
# [Ok].join # => raise Errgonomic::SerializeError, "bare Ok names a variant for a pattern, not a value; build one with parentheses"
|
|
20
|
+
# Some(1).is_a?(Some) # => raise TypeError, "class or module required"
|
|
21
|
+
# Some(1).is_a?(Errgonomic::Option::Some) # => true
|
|
22
|
+
class VariantName
|
|
23
|
+
# Name a variant at top level, refusing a constant the application
|
|
24
|
+
# already holds there rather than replacing it.
|
|
25
|
+
def self.define(name, variant)
|
|
26
|
+
if Object.const_defined?(name, false)
|
|
27
|
+
existing = Object.const_get(name)
|
|
28
|
+
return if existing.is_a?(VariantName) && existing.names?(variant)
|
|
29
|
+
|
|
30
|
+
raise NameError.new("#{name} is already defined as #{existing.inspect}; errgonomic defines #{name} " \
|
|
31
|
+
"at top level to name #{variant} in patterns, so rename the application's constant", name)
|
|
32
|
+
end
|
|
33
|
+
Object.const_set(name, new(name, variant))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(name, variant)
|
|
37
|
+
@name = name
|
|
38
|
+
@variant = variant
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def names?(variant)
|
|
43
|
+
@variant.equal?(variant)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ===(other)
|
|
47
|
+
@variant === other # rubocop:disable Style/CaseEquality
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect
|
|
51
|
+
@variant.inspect
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Refuse to stand in for a value.
|
|
55
|
+
def refuse!(*_args)
|
|
56
|
+
raise Errgonomic::SerializeError,
|
|
57
|
+
"bare #{@name} names a variant for a pattern, not a value; build one with parentheses"
|
|
58
|
+
end
|
|
59
|
+
alias to_s refuse!
|
|
60
|
+
alias to_json refuse!
|
|
61
|
+
alias as_json refuse!
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/errgonomic/version.rb
CHANGED
data/lib/errgonomic.rb
CHANGED
|
@@ -85,41 +85,4 @@ module Errgonomic
|
|
|
85
85
|
ensure
|
|
86
86
|
@give_me_ambiguous_downstream_errors = original_value
|
|
87
87
|
end
|
|
88
|
-
|
|
89
|
-
# Cross-type equality is quiet by default, as it is for every Ruby object.
|
|
90
|
-
# Strict equality turns it into an error instead, for a test suite or CI:
|
|
91
|
-
# `Some(5) == 5` is the comparison Rust rejects at compile time, and it is
|
|
92
|
-
# silently false here otherwise.
|
|
93
|
-
#
|
|
94
|
-
# @example
|
|
95
|
-
# Errgonomic.strict_equality? # => false
|
|
96
|
-
# Errgonomic.with_strict_equality { Errgonomic.strict_equality? } # => true
|
|
97
|
-
# Errgonomic.strict_equality? # => false
|
|
98
|
-
def self.strict_equality?
|
|
99
|
-
!!@strict_equality
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
class << self
|
|
103
|
-
# Turn cross-type equality into an error for the rest of the process.
|
|
104
|
-
attr_writer :strict_equality
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Turn cross-type equality into an error for the duration of the block.
|
|
108
|
-
def self.with_strict_equality
|
|
109
|
-
original_value = @strict_equality
|
|
110
|
-
@strict_equality = true
|
|
111
|
-
yield
|
|
112
|
-
ensure
|
|
113
|
-
@strict_equality = original_value
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Lenient inner value comparison means the inner value of a Some or Ok can be
|
|
117
|
-
# compared to some other non-Result or non-Option value.
|
|
118
|
-
def self.lenient_inner_value_comparison?
|
|
119
|
-
@lenient_inner_value_comparison ||= true
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def self.give_me_lenient_inner_value_comparison=(value)
|
|
123
|
-
@lenient_inner_value_comparison = value
|
|
124
|
-
end
|
|
125
88
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: errgonomic
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nick Zadrozny
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- lib/errgonomic/rails/active_record_optional.rb
|
|
91
91
|
- lib/errgonomic/result.rb
|
|
92
92
|
- lib/errgonomic/type.rb
|
|
93
|
+
- lib/errgonomic/variant_name.rb
|
|
93
94
|
- lib/errgonomic/version.rb
|
|
94
95
|
- sig/errgonomic.rbs
|
|
95
96
|
homepage: https://omc.io/
|