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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58963c6e1c1cfcf52d8fe4b065dd986acda616f4e55001d7d2bc13cbeab1eb28
4
- data.tar.gz: 9d6f93fdd27c94525f2f2a2dc53010eb4f99760dae7514eaf4f035f5c9947869
3
+ metadata.gz: 543390fc5f85546ffde4785cfc41951400800094506078271cfe32535e9d784a
4
+ data.tar.gz: 8d5cdf061dbf7026eaab5ba8cab628368c600943699dbad04235c9d1d9bd3f5a
5
5
  SHA512:
6
- metadata.gz: 2adf6eadd64c94629ba265ea9747d943314616f8c26943ad075ea5399bc86c4747e433946273989640b668e0841c337cdc6f3ecc873fed9006aa82eb439510d9
7
- data.tar.gz: 475c6a42553b89700d4de4d0d7257bcbb71388c61d8b12da69a51ce4aa30a75f7074757a498bb20d5074539748b8e799bf8c78303ef3dc8ac33a74306d615aad
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`, `rake test` and `rake test:strict` on the latest Ruby 3.4.x on ubuntu-latest, matching the Ruby pinned by the flake.
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 support pattern matching:
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 Errgonomic::Option::Some, value
118
+ in Some(value)
119
119
  "Measurement is #{value}"
120
- in Errgonomic::Option::None
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)`, but `Some(5) == 5` and `None() == nil` are `false`. That is quiet, never an error, matching how every Ruby object compares across types. Rust rejects `Some(5) == 5` at compile time; Ruby cannot, so guard the idiom in review and tests: compare against a wrapped value (`opt == Some(5)`) or test the inner value (`opt.some_and? { |v| v == 5 }`). `Errgonomic.strict_equality = true` turns that guard into an error, which is what a test suite wants; see [Pedantic runtime checks](#pedantic-runtime-checks).
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 Errgonomic::Result::Ok, value
179
+ in Ok(value)
176
180
  "Measurement is #{value}"
177
- in Errgonomic::Result::Err, String => msg
181
+ in Err(String => msg)
178
182
  "Measurement failed with a message: #{msg}"
179
- in Errgonomic::Result::Err, Exception => e
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
- Cross-type equality is the other pedantic check, and it is off by default because a quiet `false` is what every Ruby object answers. Turn it on and 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
+ ### Strict equality
257
261
 
258
- ```ruby
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, as always
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 as they always did, and `hash` is untouched, so an Option stays usable as a Hash key with it on.
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
- 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 == None()` and `"a" == Some("a")` stay quietly false, because `NilClass` and `String` answer for themselves and never consult the operand. Put the wrapper on the left in a test if you want the check to reach every comparison. It is meant for a test suite or CI, not for production, and there is a block form for scoping it the way the ambiguous-error opt-out is scoped:
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
- ```ruby
279
- Errgonomic.with_strict_equality do
280
- assert_equal Some(5), book.pages
281
- end
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
- This gem runs its own Rails integration suite that way, as `rake test:strict`.
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 still `false`. 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.
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 test:strict yard:doctest]
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'
@@ -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. Anything else, including nil and the raw inner value, is not
69
- # equal: quietly false, never an error. Rust rejects Some(5) == 5 at
70
- # compile time; Ruby cannot, and raising here would break the many
71
- # places Ruby compares heterogeneous operands (Array#include?,
72
- # assertion diffs, dirty tracking). Compare Options (opt == Some(5)) or
73
- # test the inner value (opt.some_and? { |v| v == 5 }) instead.
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 is likewise false: None is a value that represents
76
- # absence, not an absence Ruby can see. (The Rails integration
77
- # separately makes None#nil? answer true, as an ActiveRecord
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
- # Some(1) == 1 # => false
86
- # None() == nil # => false
87
- #
88
- # @example strict equality makes a cross-type comparison an error
89
- # Errgonomic.with_strict_equality do
90
- # begin
91
- # Some(5) == 5
92
- # rescue Errgonomic::TypeMismatchError => e
93
- # e.class
94
- # end
95
- # end # => Errgonomic::TypeMismatchError
96
- # Errgonomic.with_strict_equality do
97
- # begin
98
- # Some(5) != 5
99
- # rescue Errgonomic::TypeMismatchError => e
100
- # e.message.include?("!=")
101
- # end
102
- # end # => true
103
- # Errgonomic.with_strict_equality { Some(5) == Some(5) } # => true
104
- # Errgonomic.with_strict_equality { Some(5) == None() } # => false
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.with_strict_equality do
108
- # begin
109
- # Some(1) == Ok(1)
110
- # rescue Errgonomic::TypeMismatchError => e
111
- # e.message.include?("different containers")
112
- # end
113
- # end # => true
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.with_strict_equality do
117
- # begin
118
- # None() == nil
119
- # rescue Errgonomic::TypeMismatchError => e
120
- # e.message.include?("none?")
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
- # end # => true
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 strict equality reaches eql?, and leaves hash alone
144
- # Errgonomic.with_strict_equality do
145
- # begin
146
- # Some(5).eql?(5)
147
- # rescue Errgonomic::TypeMismatchError => e
148
- # e.class
149
- # end
150
- # end # => Errgonomic::TypeMismatchError
151
- # Errgonomic.with_strict_equality { Some(5).hash == Some(5).hash } # => true
152
- # Ruby derives != from ==, so a strict-equality message would name the
153
- # operator the caller did not write.
154
- def !=(other)
155
- strict_equality!(other, '!=')
156
- super
157
- end
158
-
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
- # measurement = Errgonomic::Option::Some.new(1)
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 Errgonomic::Option::Some, value
216
+ # in Some(value)
181
217
  # "Measurement is #{value}"
182
- # in Errgonomic::Option::None
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
- return [self, value] if some?
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 stays false. Nor does Array#compact, the common
22
- # collection idiom for dropping absent members: it tests for the
23
- # nil object, so it keeps a None where reject(&:none?) drops it.
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.unwrap_or(nil)
520
+ when Errgonomic::Option::Any, Errgonomic::VariantName
521
+ unwrap_option(value)
521
522
  when Array
522
- value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value
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?(Errgonomic::Option::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?(Errgonomic::Option::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
@@ -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
- # Equality comparison for Result objects is based on value not reference.
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
- # Ok(1) == 1 # => false
117
- # Err() == nil # => false
118
- #
119
- # @example strict equality makes a cross-type comparison an error
120
- # Errgonomic.with_strict_equality do
121
- # begin
122
- # Ok(1) == 1
123
- # rescue Errgonomic::TypeMismatchError => e
124
- # e.class
125
- # end
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
- # Errgonomic.with_strict_equality { Ok(1) == Ok(1) } # => true
128
- # Errgonomic.with_strict_equality do
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.with_strict_equality do
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 strict equality reaches eql?, and leaves hash alone
163
- # Errgonomic.with_strict_equality do
164
- # begin
165
- # Ok(5).eql?(5)
166
- # rescue Errgonomic::TypeMismatchError => e
167
- # e.class
168
- # end
169
- # end # => Errgonomic::TypeMismatchError
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
- # @example simple pattern match with variable capture of the value
475
- # result = Errgonomic::Result::Ok.new(1)
476
- # case result
477
- # in Errgonomic::Result::Ok, value
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 Errgonomic::Result::Err, err
505
+ # in Err(err)
480
506
  # "Measurement is not available"
481
507
  # end # => "Measurement is 1"
482
508
  #
483
- # @example more advanced pattern match against the kind of value
484
- # result = Errgonomic::Result::Err.new(StandardError.new("nope"))
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 Errgonomic::Result::Ok, value
532
+ # in Ok(value)
487
533
  # "Measurement is #{value}"
488
- # in Errgonomic::Result::Err, String => msg
534
+ # in Err(String => msg)
489
535
  # "Measurement failed with a message: #{msg}"
490
- # in Errgonomic::Result::Err, Exception => e
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
- [self, value]
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
- # Errgonomic.with_strict_equality { Err(Some(1)).inspect } # => "Err(Some(1))"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errgonomic
4
- VERSION = '0.9.3'
4
+ VERSION = '0.10.1'
5
5
  end
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.9.3
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/