diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eb7e37e..69b76cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,5 +29,3 @@ jobs: run: bundle exec yard doctest - name: Run rake test run: bundle exec rake test - - name: Run rake test:strict - run: bundle exec rake test:strict diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a08fe..bf4cfc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ ## [Unreleased] +## [0.10.2] - 2026-09-10 + +This release makes every `Err` carry an error and removes `Option#ok`, the one method that built an `Err` without one. + +### Upgrading from 0.10.1 + +Replace `Err()` with `Err(reason)`, and a pattern `in Err()` with `in Err` or `in Err(_)`: `Err()` and `Errgonomic::Result::Err.new` with no argument raise `ArgumentError`, and `in Err()` no longer matches any `Err`. Replace `opt.ok` with `opt.ok_or(reason)`; `ok` on an Option now raises `Errgonomic::UnwrappedAccessError`, which names `ok_or` and `ok_or_else`. + +### Changes + +- [Behavior change] `Err()` and `Errgonomic::Result::Err.new` with no argument raise `ArgumentError`. A value-less `Err` held an internal placeholder, `Errgonomic::Result::Err::Arbitrary`, which `unwrap_err!` handed back to the caller, a gem-internal value leaking into application data. The placeholder is deleted along with the special case that made `Err().deconstruct` answer `[]`, so `Err(e).deconstruct` is always `[e]`, `in Err(e)` and `in Err(_)` match every `Err`, and `in Err()` matches none. `Err(nil)` is unchanged: it carries `nil` +- `Option#ok` is removed. `None().ok` was the only method that returned a value-less `Err`, and Rust has no `Option::ok`; `ok_or` and `ok_or_else` make the caller name the error +- [Docs] The README's Known limitations section names a converted model's `belongs_to ..., optional: true, touch: true`, which raises `Errgonomic::UnwrappedAccessError` on a save or a destroy with the association absent because a `None` does not delegate `persisted?`, and gives `errgonomic_optional_except` as the way around it. The limitation predates 0.10.2 + +## [0.10.1] - 2026-09-10 + +This release makes cross-type equality raise unconditionally and removes the switch that used to turn it on. + +### Upgrading from 0.10.0 + +`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. + +### Changes + +- [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 +- `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 +- `Errgonomic.lenient_inner_value_comparison?` and `Errgonomic.give_me_lenient_inner_value_comparison=` are removed; nothing read them +- [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` + +## [0.10.0] - 2026-09-10 + +This release gives `deconstruct` the Rust shape and names the four variants at top level, so a pattern reads as `in Some(v)`. + +### Upgrading from 0.9.3 + +`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`. + +### Changes + +- [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 +- 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 +- `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 + +## [0.9.3] - 2026-09-10 + +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. + +### Upgrading from 0.9.2 + +`value` and `value=` are gone from `Some`, `Ok` and `Err`, and every instance is frozen as it is constructed. A read of `.value` becomes `unwrap_or(fallback)`, `expect!(message)`, `map`, `and_then` or a pattern, each of which names the other branch; a write of `.value=` becomes a new `Some(v)` assigned where the old one lived. A call to either now raises `Errgonomic::UnwrappedAccessError`, which is a `NoMethodError`, naming the combinators. + +### Changes + +- [Behavior change] `Some`, `Ok` and `Err` no longer expose `value` or `value=`, and every Option and Result is frozen as it is constructed, whether by `Some`, `None`, `Ok`, `Err`, `new` or a combinator; `clone` keeps it frozen. 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`. The reader reached the inner value with no `None` branch, the writer mutated a wrapper through an alias and moved a Hash key out from under its own bucket, and the README already said an Option is a value rather than a slot. The reader is protected, for the sibling reads equality, ordering and `zip` need; a call from outside gets the combinator teaching `Errgonomic::UnwrappedAccessError` gives any other miss + +## [0.9.2] - 2026-09-10 + +This release makes `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did. + +### Upgrading from 0.9.1 + +`Option#and`, `#xor`, `#zip` and `#zip_with` raise `Errgonomic::ArgumentError` on a bare operand, on a `None` receiver as well as a `Some`. Code that passed a bare value to `and` and read it back has to wrap it. + +### Changes + +- [Behavior change] `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did, raising `Errgonomic::ArgumentError` (`other must be an Option, was Integer`) before the receiver's variant is consulted. 0.9.x let `Some(2).and(3)` hand back the bare `3`, let `None().and(3)` and `None().zip(2)` accept the operand silently, and let `Some(1).zip(2)` and `Some(:l).xor(:r)` fall into a bare `NoMethodError` on `some?` or `none?` + ## [0.9.1] - 2026-09-10 This release reverts the 0.9.0 change that made `to_s` render an Option or a Result. The raise is back, with a message that says what to call instead. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c31ff1c..1184dfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,6 @@ The inner rungs run constantly; the outer rungs are slower and run when preparin bundle exec rubocop # formatted & lint-clean bundle exec yard doctest # doctests pass — most behavior is specified here bundle exec rake test # unit tests pass (incl. the Rails integration test) -bundle exec rake test:strict # the same suite with cross-type equality raising ``` 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 nix flake check --all-systems # builds every check on the local system, evaluates all four ``` -**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. +**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. 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. diff --git a/Gemfile.lock b/Gemfile.lock index efedd05..3249b50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.9.1) + errgonomic (0.10.2) concurrent-ruby (~> 1.0) GEM @@ -385,7 +385,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 - errgonomic (0.9.1) + errgonomic (0.10.2) erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009 diff --git a/README.md b/README.md index bcd9db2..3241f34 100644 --- a/README.md +++ b/README.md @@ -111,17 +111,21 @@ Some(1).each.to_a # => [1] `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. -Options support pattern matching: +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: ```ruby case measurement -in Errgonomic::Option::Some, value +in Some(value) "Measurement is #{value}" -in Errgonomic::Option::None +in None "Measurement is not available" end ``` +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. + +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`. + 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). 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`. @@ -144,9 +148,9 @@ Writers unwrap under that integration, which changes what a truthiness slip cost The remaining present-side helpers are soft-deprecated on Options in favor of the combinators. They unwrap, where on any other object they return the receiver: `Some(v).present_or_raise!(msg)`, `present_or(default)` and `present_or_else { }` all yield `v`, and `None` raises, substitutes, or computes. Each prints a one-line stderr nudge naming the combinator to use instead (`expect!`, `unwrap_or`, `unwrap_or_else`), once per process per method rather than once per call, so a hot path does not flood the log. The blank side (`blank_or*`) raises `UnwrappedAccessError` outright: an Option's blankness is its discriminant, so test it with `none?`. -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. +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. -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). +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. 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?`. @@ -172,11 +176,11 @@ Results also pattern match, including against the kind of inner value: ```ruby case result -in Errgonomic::Result::Ok, value +in Ok(value) "Measurement is #{value}" -in Errgonomic::Result::Err, String => msg +in Err(String => msg) "Measurement failed with a message: #{msg}" -in Errgonomic::Result::Err, Exception => e +in Err(Exception => e) "Measurement produced an exception -- #{e.class}: #{e}" end ``` @@ -253,35 +257,37 @@ Errgonomic.with_ambiguous_downstream_errors do end ``` -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: +### Strict equality -```ruby -Errgonomic.strict_equality = true +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: +```ruby Some(5) == 5 # => raises Errgonomic::TypeMismatchError Some(5) != 5 # => raises Some(5).eql?(5) # => raises +Some(5) === 5 # => raises, so `case 5 when Some(5)` raises too None() == nil # => raises, pointing at none? Ok(1) == 1 # => raises Some(1) == Ok(1) # => raises: an Option and a Result are different containers -Some(5) == Some(5) # => true, as always +Some(5) == Some(5) # => true +Some(5) == None() # => false 1 == Some(1) # => raises, through Integer's coercion fallback nil == None() # => false, quietly "a" == Some("a") # => false, quietly ``` -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. +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. -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: +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. -```ruby -Errgonomic.with_strict_equality do - assert_equal Some(5), book.pages -end -``` +- 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. +- 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. +- 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. + +Strict equality never answers wrong; it only sometimes fails to catch. -This gem runs its own Rails integration suite that way, as `rake test:strict`. +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. ### Rails integration @@ -409,7 +415,7 @@ It is available on every model, converted or not, because it lifts both ends one 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: -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. +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. 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. 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. 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. @@ -417,6 +423,10 @@ This is the register of where the gem leaves the Rust idiom, and why. ActiveReco The set is closed. If a future integration appears to need a sixth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. `errgonomic_optional_except` and `errgonomic_serialize_none` are deliberately not on the list: they are configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next and a choice of how an absent value is written, rather than semantic exceptions. +#### Known limitations + +A converted model with `belongs_to :writer, optional: true, touch: true` raises `Errgonomic::UnwrappedAccessError` (``undefined method `persisted?' for None``) when it saves or is destroyed with the association absent: on `create`, on any `update`, and on `destroy`. ActiveRecord's touch callback reads the association back through the public reader and asks `record && record.persisted?`, and a `None` is truthy and does not delegate `persisted?` the way a `Some` does. Leave the association unwrapped with `errgonomic_optional_except :writer`, which keeps `touch: true` working and hands back `nil` for an absent record. + ## Development After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. The repository is a self-contained Nix flake; with direnv, `direnv allow` puts the right toolchain on your path. diff --git a/Rakefile b/Rakefile index af5b7cf..034cf81 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'bundler/gem_tasks' -require 'shellwords' require 'rake/testtask' Rake::TestTask.new(:test) do |t| @@ -17,16 +16,9 @@ YARD::Doctest::RakeTask.new do |task| task.pattern = FileList['lib/**/*.rb'].join(' ') end -namespace :test do - desc 'Run the Rails integration suite with strict equality on' - task :strict do - ruby '-Ilib', '-Itest', 'test/support/strict_equality.rb', *Shellwords.split(ENV.fetch('TESTOPTS', '')) - end -end - # yard:doctest ends the process when it finishes, so anything after it in # the default list would never run. -task default: %i[test test:strict yard:doctest] +task default: %i[test yard:doctest] namespace :gems4nix do desc 'Regenerate gem-groups.json after Gemfile/Gemfile.lock changes' diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 1d41b1e..c26f040 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -85,41 +85,4 @@ def self.with_ambiguous_downstream_errors ensure @give_me_ambiguous_downstream_errors = original_value end - - # Cross-type equality is quiet by default, as it is for every Ruby object. - # Strict equality turns it into an error instead, for a test suite or CI: - # `Some(5) == 5` is the comparison Rust rejects at compile time, and it is - # silently false here otherwise. - # - # @example - # Errgonomic.strict_equality? # => false - # Errgonomic.with_strict_equality { Errgonomic.strict_equality? } # => true - # Errgonomic.strict_equality? # => false - def self.strict_equality? - !!@strict_equality - end - - class << self - # Turn cross-type equality into an error for the rest of the process. - attr_writer :strict_equality - end - - # Turn cross-type equality into an error for the duration of the block. - def self.with_strict_equality - original_value = @strict_equality - @strict_equality = true - yield - ensure - @strict_equality = original_value - end - - # Lenient inner value comparison means the inner value of a Some or Ok can be - # compared to some other non-Result or non-Option value. - def self.lenient_inner_value_comparison? - @lenient_inner_value_comparison ||= true - end - - def self.give_me_lenient_inner_value_comparison=(value) - @lenient_inner_value_comparison = value - end end diff --git a/lib/errgonomic/core_ext/enumerable.rb b/lib/errgonomic/core_ext/enumerable.rb index e852e2a..fd62017 100644 --- a/lib/errgonomic/core_ext/enumerable.rb +++ b/lib/errgonomic/core_ext/enumerable.rb @@ -46,7 +46,7 @@ def sequence_options end return None() if member.none? - values << member.value + member.tap_some { |value| values << value } end Some(values) end @@ -87,7 +87,7 @@ def sequence_results end return member if member.err? - values << member.value + member.tap_ok { |value| values << value } end Ok(values) end diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 736f717..1055ccf 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -2,6 +2,7 @@ require 'set' require 'stringio' +require_relative 'variant_name' module Errgonomic module Option @@ -65,61 +66,84 @@ def respond_to_missing?(name, include_private = false) end # An Option equals another Option of the same class with an equal inner - # value. Anything else, including nil and the raw inner value, is not - # equal: quietly false, never an error. Rust rejects Some(5) == 5 at - # compile time; Ruby cannot, and raising here would break the many - # places Ruby compares heterogeneous operands (Array#include?, - # assertion diffs, dirty tracking). Compare Options (opt == Some(5)) or - # test the inner value (opt.some_and? { |v| v == 5 }) instead. + # value. Comparing it with anything that is not an Option raises + # Errgonomic::TypeMismatchError, naming both sides and the spelling to + # reach for. Some(5) == 5 is the comparison Rust rejects at compile + # time, and a quiet false there is a silent wrong branch, the same + # failure as a wrapper written into a string. The raise reaches ==, !=, + # eql? and ===, and through them every collection operation that + # compares pairwise. Ruby's hashing compares hash values first and asks + # eql? only of a candidate whose hash matches, so a Hash lookup, a Set + # and uniq stay quiet with a wrong-typed key: strict equality never + # answers wrong, it only sometimes fails to catch. nil == Some(1) is + # answered by NilClass and cannot be intercepted. # - # None() == nil is likewise false: None is a value that represents - # absence, not an absence Ruby can see. (The Rails integration - # separately makes None#nil? answer true, as an ActiveRecord - # compromise; equality does not follow it.) + # None() == nil raises too: None is a value that represents absence, + # not an absence Ruby can see, and the message points at none?. (The + # Rails integration separately makes None#nil? answer true, as an + # ActiveRecord compromise; equality does not follow it.) # # @example # Some(1) == Some(1) # => true # Some(1) == Some(2) # => false # Some(1) == None() # => false # None() == None() # => true - # Some(1) == 1 # => false - # None() == nil # => false - # - # @example strict equality makes a cross-type comparison an error - # Errgonomic.with_strict_equality do - # begin - # Some(5) == 5 - # rescue Errgonomic::TypeMismatchError => e - # e.class - # end - # end # => Errgonomic::TypeMismatchError - # Errgonomic.with_strict_equality do - # begin - # Some(5) != 5 - # rescue Errgonomic::TypeMismatchError => e - # e.message.include?("!=") - # end - # end # => true - # Errgonomic.with_strict_equality { Some(5) == Some(5) } # => true - # Errgonomic.with_strict_equality { Some(5) == None() } # => false + # + # @example a cross-type comparison is an error, never a quiet false + # 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." + # 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." + # 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." + # Some(5) === Some(5) # => true + # 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." # # @example a Result is another container, not another Option - # Errgonomic.with_strict_equality do - # begin - # Some(1) == Ok(1) - # rescue Errgonomic::TypeMismatchError => e - # e.message.include?("different containers") - # end - # end # => true + # 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))." # # @example nil is another type, and absence here is the discriminant - # Errgonomic.with_strict_equality do - # begin - # None() == nil - # rescue Errgonomic::TypeMismatchError => e - # e.message.include?("none?") + # 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." + # + # @example the raise reaches every operation that compares pairwise + # begin + # [Some(1)].include?(1) + # rescue Errgonomic::TypeMismatchError => e + # e.class + # end # => Errgonomic::TypeMismatchError + # begin + # [Some(1)] == [1] + # rescue Errgonomic::TypeMismatchError => e + # e.class + # end # => Errgonomic::TypeMismatchError + # begin + # [Some(1), 1] - [1] + # rescue Errgonomic::TypeMismatchError => e + # e.class + # end # => Errgonomic::TypeMismatchError + # begin + # { a: Some(1) } == { a: 1 } + # rescue Errgonomic::TypeMismatchError => e + # e.class + # end # => Errgonomic::TypeMismatchError + # begin + # case 5 + # when Some(5) then :hit # end - # end # => true + # rescue Errgonomic::TypeMismatchError => e + # e.class + # end # => Errgonomic::TypeMismatchError + # + # @example hashing compares hash values first, so these stay quiet + # { Some(1) => :v }[1] # => nil + # Set[Some(1)].include?(1) # => false + # [Some(1), 1].uniq # => [Some(1), 1] + # + # @example a short array compares member by member with eql?, so the side the wrapper is on decides + # [1] - [Some(1)] # => [1] + # [Some(1)] | [1] # => [Some(1), 1] + # [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." + # + # @example nil and String answer for themselves, and never ask the Option + # nil == Some(1) # => false + # "a" == Some("a") # => false def ==(other) strict_equality!(other, '==') return false if self.class != other.class @@ -128,6 +152,13 @@ def ==(other) value == other.value end + # Object#=== is ==, so a `case value when Some(5)` and a pinned pattern + # reach the same check, named for the operator that was written. + def ===(other) + strict_equality!(other, '===') + self == other + end + # Hash-based collections (Hash keys, Set, uniq, group_by) use eql? and # hash, not ==. Follow the inner value's own eql? semantics, so Options # behave as keys exactly like their inner values: Some(1) and Some(1.0) @@ -140,22 +171,9 @@ def ==(other) # { Some(5) => 1 }[Some(5)] # => 1 # [Some(1), Some(1), None(), None()].uniq # => [Some(1), None()] # - # @example strict equality reaches eql?, and leaves hash alone - # Errgonomic.with_strict_equality do - # begin - # Some(5).eql?(5) - # rescue Errgonomic::TypeMismatchError => e - # e.class - # end - # end # => Errgonomic::TypeMismatchError - # Errgonomic.with_strict_equality { Some(5).hash == Some(5).hash } # => true - # Ruby derives != from ==, so a strict-equality message would name the - # operator the caller did not write. - def !=(other) - strict_equality!(other, '!=') - super - end - + # @example a cross-type eql? raises as == does, and hash is untouched + # 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." + # Some(5).hash == Some(5).hash # => true def eql?(other) strict_equality!(other, 'eql?') return false if self.class != other.class @@ -164,6 +182,13 @@ def eql?(other) value.eql?(other.value) end + # Ruby derives != from ==, so a strict-equality message would name the + # operator the caller did not write. + def !=(other) + strict_equality!(other, '!=') + super + end + # @example # Some(5).hash == Some(5).hash # => true # None().hash == None().hash # => true @@ -174,20 +199,65 @@ def hash [self.class, value].hash end + # The Rust shape: a Some deconstructs to its one payload and a None to + # nothing, so `in Some(v)` binds the value and `in None` matches. There + # is no deconstruct_keys, because a one-payload sum type has no named + # field; a Some wrapping a Hash nests as `in Some({id:})` through the + # Hash's own protocol. + # # @example - # measurement = Errgonomic::Option::Some.new(1) + # Some(1).deconstruct # => [1] + # None().deconstruct # => [] + # Some(1).respond_to?(:deconstruct_keys) # => false + # + # @example a two-branch case/in with no else is exhaustive + # measurement = Some(1) # case measurement - # in Errgonomic::Option::Some, value - # "Measurement is #{measurement.value}" - # in Errgonomic::Option::None + # in Some(value) + # "Measurement is #{value}" + # in None # "Measurement is not available" - # else - # "not matched" # end # => "Measurement is 1" + # case None() + # in Some(value) + # "Measurement is #{value}" + # in None + # "Measurement is not available" + # end # => "Measurement is not available" + # + # @example the wrong type falls through to Ruby's own exhaustiveness check + # begin + # case 1 + # in Some(value) then value + # in None then nil + # end + # rescue NoMatchingPatternError => e + # [e.class, e.message] + # end # => [NoMatchingPatternError, "1"] + # + # @example a Result that falls through carries a message that refuses to print + # begin + # case Ok(1) + # in Some(value) then value + # in None then nil + # end + # rescue NoMatchingPatternError => e + # [e.class, (e.message rescue $!.class)] + # end # => [NoMatchingPatternError, Errgonomic::SerializeError] + # + # @example patterns nest through the inner value's own protocol + # case Ok(Some(1)) + # in Ok(Some(value)) then value + # end # => 1 + # case Some({ id: 7, name: 'x' }) + # in Some({ id: }) then id + # end # => 7 + # case Some(1) + # in Errgonomic::Option::Some(value) then "bound #{value}" + # else "not matched" + # end # => "bound 1" def deconstruct - return [self, value] if some? - - [Errgonomic::Option::None] + to_a end # Options order like Rust's: None sorts before any Some, and Somes @@ -556,21 +626,24 @@ def map_or_else(proc, &block) block.call(value) end - # convert the option into a result where Some is Ok and None is Err - # @example - # None().ok # => Err() - # Some(1).ok # => Ok(1) - def ok - return Errgonomic::Result::Ok.new(value) if some? - - Errgonomic::Result::Err.new - end - # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err) # # @example # None().ok_or("wow") # => Err("wow") # Some(1).ok_or("such err") # => Ok(1) + # + # @example there is no bare ok: an Err always names its error + # begin + # None().ok + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # begin + # Some(1).ok + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # Some(1).respond_to?(:ok) # => false def ok_or(err) return Errgonomic::Result::Ok.new(value) if some? @@ -594,10 +667,10 @@ def ok_or_else(&block) # @example # None().or(Some(1)) # => Some(1) # Some(2).or(Some(3)) # => Some(2) - # None().or(2) # => raise Errgonomic::ArgumentError.new, "other must be an Option, was Integer" + # None().or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # Some(1).or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def or(other) - raise ArgumentError, "other must be an Option, was #{other.class.name}" unless other.is_a?(Any) - + option_operand!(other) return self if some? other @@ -620,12 +693,17 @@ def or_else(&block) val end - # If self is Some, return the provided other Option. + # If self is Some, return the provided other Option. The operand is + # checked on both variants, so a None-heavy path still learns that it + # was handed a bare value. # # @example # None().and(Some(1)) # => None() # Some(2).and(Some(3)) # => Some(3) + # Some(2).and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def and(other) + option_operand!(other) return self if none? other @@ -657,7 +735,10 @@ def and_then(&block) # None().zip(Some(1)) # => None() # Some(1).zip(None()) # => None() # Some(2).zip(Some(3)) # => Some([2, 3]) + # Some(1).zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def zip(other) + option_operand!(other) return None() unless some? && other.some? Some([value, other.value]) @@ -671,7 +752,10 @@ def zip(other) # None().zip_with(Some(1)) { |a, b| a + b } # => None() # Some(1).zip_with(None()) { |a, b| a + b } # => None() # Some(2).zip_with(Some(3)) { |a, b| a + b } # => Some(5) + # Some(1).zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def zip_with(other, &block) + option_operand!(other) return None() unless some? && other.some? other = block.call(value, other.value) @@ -767,8 +851,10 @@ def flatten # Some(:left).xor(Some(:right)) # => None() # Some(:left).xor(None()) #=> Some(:left) # None().xor(Some(:right)) #=> Some(:right) - # + # Some(:left).xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol" + # None().xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol" def xor(other) + option_operand!(other) return self if some? && other.none? return other if other.some? && none? @@ -777,6 +863,14 @@ def xor(other) private + # Checked before the discriminant is consulted, so a None-heavy path + # learns about a bare operand as soon as a Some-heavy one would. + def option_operand!(other) + return if other.is_a?(Errgonomic::Option::Any) + + raise Errgonomic::ArgumentError, "other must be an Option, was #{other.class.name}" + end + def to_s_refusal "#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" end @@ -799,7 +893,6 @@ def presence_nudge(from, to) end def strict_equality!(other, operator) - return unless Errgonomic.strict_equality? return if other.is_a?(Errgonomic::Option::Any) raise Errgonomic::TypeMismatchError, @@ -830,11 +923,47 @@ def raise_blank_side_teaching(name) # Represent a value class Some < Any - attr_accessor :value - + # A Some is a value, not a slot: nothing outside reads the inner value + # without handling the None branch, and nothing swaps it out from under + # another reference or a Hash key. + # + # @example the inner value is reached through a combinator, never a reader + # begin + # Some(1).value + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # Some(1).respond_to?(:value) # => false + # + # @example a Some cannot be mutated through an alias + # a = Some(1) + # b = a + # begin + # b.value = 99 + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # a # => Some(1) + # Some(1).frozen? # => true + # begin + # Some(1).instance_variable_set(:@value, 2) + # rescue FrozenError => e + # e.class + # end # => FrozenError + # + # @example a Some keeps its place as a Hash key + # k = Some(1) + # h = { k => :v } + # begin + # k.value = 2 + # rescue NoMethodError + # nil + # end + # h[k] # => :v def initialize(value) super() @value = value + freeze end def some? @@ -856,10 +985,28 @@ def none? def inspect "Some(#{value.inspect})" end + + protected + + # Sibling instances read each other's value for equality, ordering and + # zip; nothing else does. + attr_reader :value end # Represent the absence of a value. class None < Any + # @example a None has no value to read, and says so the same way a Some does + # begin + # None().value + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # None().frozen? # => true + def initialize + super + freeze + end + def some? false end @@ -886,3 +1033,8 @@ def Some(value) def None Errgonomic::Option::None.new end + +# The variants under their short names, so a pattern reads as it does in +# Rust: `in Some(v)`, `in None`. +Errgonomic::VariantName.define(:Some, Errgonomic::Option::Some) +Errgonomic::VariantName.define(:None, Errgonomic::Option::None) diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index a1ceda4..6279ef1 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -18,9 +18,10 @@ module Rails # # 1. None#nil? answers true, so AR internals and ordinary nil checks # treat an absent value as absent. Equality does not follow suit: - # None() == nil stays 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. + # None() == nil raises, as any cross-type comparison does. 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. # 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. @@ -516,10 +517,10 @@ def build(attribute, value, *args) # Errgonomic::Rails.unwrap_options(1) # => 1 def self.unwrap_options(value) case value - when Errgonomic::Option::Any - value.unwrap_or(nil) + when Errgonomic::Option::Any, Errgonomic::VariantName + unwrap_option(value) when Array - value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value + value.any? { |v| unwrapped_at_boundary?(v) } ? value.map { |v| unwrap_options(v) } : value else value end @@ -527,13 +528,16 @@ def self.unwrap_options(value) # Take the value inside an Option, and a None as nil, where the boundary # takes one value: an attribute is a single typed field, so a collection - # that happens to hold an Option is that collection. + # that happens to hold an Option is that collection. A bare variant name + # is refused here, since a boolean column would cast it to true. # # @example # Errgonomic::Rails.unwrap_option(Some(1)) # => 1 # Errgonomic::Rails.unwrap_option(None()) # => nil # Errgonomic::Rails.unwrap_option([Some(1)]) # => [Some(1)] + # Errgonomic::Rails.unwrap_option(None) # => raise Errgonomic::SerializeError, "bare None names a variant for a pattern, not a value; build one with parentheses" def self.unwrap_option(value) + value.refuse! if value.is_a?(Errgonomic::VariantName) value.is_a?(Errgonomic::Option::Any) ? value.unwrap_or(nil) : value end @@ -548,7 +552,7 @@ def self.unwrap_option(value) # plain = { title: 'x' } # Errgonomic::Rails.unwrap_option_values(plain).equal?(plain) # => true def self.unwrap_option_values(hash) - return hash unless hash.each_value.any?(Errgonomic::Option::Any) + return hash unless hash.each_value.any? { |value| unwrapped_at_boundary?(value) } hash.transform_values { |value| unwrap_option(value) } end @@ -561,11 +565,17 @@ def self.unwrap_option_values(hash) # plain = [{ title: 'x' }] # Errgonomic::Rails.unwrap_option_rows(plain).equal?(plain) # => true def self.unwrap_option_rows(rows) - return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any?(Errgonomic::Option::Any) } + return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any? { |v| unwrapped_at_boundary?(v) } } rows.map { |row| row.is_a?(Hash) ? unwrap_option_values(row) : row } end + # An Option, or a bare variant name, which a boundary refuses rather than + # let a column type cast it. + def self.unwrapped_at_boundary?(value) + value.is_a?(Errgonomic::Option::Any) || value.is_a?(Errgonomic::VariantName) + end + # A declared default that is a Proc is not a value yet: ActiveModel calls # it with no arguments each time a record is built. Wrap it rather than # unwrap it, so what it returns meets the type where a literal default diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 59447bf..8886828 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -1,17 +1,36 @@ # frozen_string_literal: true +require_relative 'variant_name' + module Errgonomic module Result # The base class for Result's Ok and Err class variants. We implement as - # much logic as possible here, and let Ok and Err handle their - # initialization and self identification. + # much logic as possible here, including construction, and let Ok and + # Err handle only their self identification. class Any include Comparable - attr_reader :value - + # A Result is a value, not a slot: the inner value is reached through a + # combinator that handles the other variant, and nothing swaps it out + # from under another reference. + # + # @example + # begin + # Ok(1).value + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # begin + # Err(:x).value = :y + # rescue NoMethodError => e + # e.class + # end # => Errgonomic::UnwrappedAccessError + # Ok(1).respond_to?(:value) # => false + # Ok(1).frozen? # => true + # Err(:x).frozen? # => true def initialize(value) @value = value + freeze end # Results order like Rust's: Ok sorts before any Err, and same variants @@ -88,7 +107,11 @@ def respond_to_missing?(name, include_private = false) RUST_SPELLINGS.key?(name) || super end - # Equality comparison for Result objects is based on value not reference. + # A Result equals another Result of the same variant with an equal + # inner value. Comparing it with anything that is not a Result raises + # Errgonomic::TypeMismatchError, on the terms Option#== states: the + # raise reaches ==, !=, eql? and ===, hashing stays quiet, and a bare + # value on the left answers for itself. # # @param other [Object] # @@ -96,34 +119,23 @@ def respond_to_missing?(name, include_private = false) # Ok(1) == Ok(1) # => true # Ok(1) == Err(1) # => false # Ok(1).object_id == Ok(1).object_id # => false - # Ok(1) == 1 # => false - # Err() == nil # => false - # - # @example strict equality makes a cross-type comparison an error - # Errgonomic.with_strict_equality do - # begin - # Ok(1) == 1 - # rescue Errgonomic::TypeMismatchError => e - # e.class - # end + # + # @example a cross-type comparison is an error, never a quiet false + # 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." + # 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." + # 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." + # Err(:x) == 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." + # Ok(1) === Ok(1) # => true + # begin + # [Ok(1)].include?(1) + # rescue Errgonomic::TypeMismatchError => e + # e.class # end # => Errgonomic::TypeMismatchError - # Errgonomic.with_strict_equality { Ok(1) == Ok(1) } # => true - # Errgonomic.with_strict_equality do - # begin - # Ok(1) != 1 - # rescue Errgonomic::TypeMismatchError => e - # e.message.include?("!=") - # end - # end # => true + # { Ok(1) => :v }[1] # => nil + # nil == Err(:x) # => false # # @example an Option is another container, not another Result - # Errgonomic.with_strict_equality do - # begin - # Ok(1) == Some(1) - # rescue Errgonomic::TypeMismatchError => e - # e.message.include?("different containers") - # end - # end # => true + # 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))." def ==(other) strict_equality!(other, '==') return false if self.class != other.class @@ -131,6 +143,13 @@ def ==(other) value == other.value end + # Object#=== is ==, so a `case value when Ok(1)` and a pinned pattern + # reach the same check, named for the operator that was written. + def ===(other) + strict_equality!(other, '===') + self == other + end + # Hash-based collections (Hash keys, Set, uniq, group_by) use eql? and # hash, not ==. Follow the inner value's own eql? semantics, so Results # behave as keys exactly like their inner values. @@ -142,15 +161,14 @@ def ==(other) # { Ok(5) => 1 }[Ok(5)] # => 1 # [Err(:a), Err(:a)].uniq # => [Err(:a)] # - # @example strict equality reaches eql?, and leaves hash alone - # Errgonomic.with_strict_equality do - # begin - # Ok(5).eql?(5) - # rescue Errgonomic::TypeMismatchError => e - # e.class - # end - # end # => Errgonomic::TypeMismatchError - # Errgonomic.with_strict_equality { Ok(5).hash == Ok(5).hash } # => true + # @example a cross-type eql? raises as == does, and hash is untouched + # 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." + # Ok(5).hash == Ok(5).hash # => true + def eql?(other) + strict_equality!(other, 'eql?') + self.class == other.class && value.eql?(other.value) + end + # Ruby derives != from ==, so a strict-equality message would name the # operator the caller did not write. def !=(other) @@ -158,11 +176,6 @@ def !=(other) super end - def eql?(other) - strict_equality!(other, 'eql?') - self.class == other.class && value.eql?(other.value) - end - # @example # Ok(5).hash == Ok(5).hash # => true # Ok(5).hash == Err(5).hash # => false @@ -454,29 +467,87 @@ def pretty_print(pp) pp.text(inspect) end - # @example simple pattern match with variable capture of the value - # result = Errgonomic::Result::Ok.new(1) - # case result - # in Errgonomic::Result::Ok, value + # The Rust shape: each variant deconstructs to its one payload, so + # `in Ok(v)` binds the value and `in Err(e)` binds the error. + # + # @example + # Ok(1).deconstruct # => [1] + # Err(:x).deconstruct # => [:x] + # Ok(1).respond_to?(:deconstruct_keys) # => false + # + # @example every Err carries an error, so `in Err()` matches none of them + # case Err(:x) + # in Err() then :empty + # in Err(e) then e + # end # => :x + # case Err(:x) + # in Err then :any_err + # end # => :any_err + # case Err(:x) + # in Err(_) then :any_err + # end # => :any_err + # + # @example an Err nested in an Ok matches through the Ok's payload + # case Ok(Err(:boom)) + # in Ok(Err(e)) then e + # end # => :boom + # case Ok(Err(:boom)) + # in Ok(Err()) then :empty + # in Ok(Err(_)) then :err_inside + # end # => :err_inside + # case Ok(Err(:boom)) + # in Ok(Ok(value)) then value + # in Ok(Err) then :err_inside + # end # => :err_inside + # + # @example a two-branch case/in with no else is exhaustive + # case Ok(1) + # in Ok(value) # "Measurement is #{value}" - # in Errgonomic::Result::Err, err + # in Err(err) # "Measurement is not available" # end # => "Measurement is 1" # - # @example more advanced pattern match against the kind of value - # result = Errgonomic::Result::Err.new(StandardError.new("nope")) + # @example the wrong type falls through to Ruby's own exhaustiveness check + # begin + # case :done + # in Ok(value) then value + # in Err(err) then err + # end + # rescue NoMatchingPatternError => e + # [e.class, e.message] + # end # => [NoMatchingPatternError, "done"] + # + # @example an Option that falls through carries a message that refuses to print + # begin + # case Some(1) + # in Ok(value) then value + # in Err(err) then err + # end + # rescue NoMatchingPatternError => e + # [e.class, (e.message rescue $!.class)] + # end # => [NoMatchingPatternError, Errgonomic::SerializeError] + # + # @example a pattern reaches the kind of value inside the variant + # result = Err(StandardError.new("nope")) # case result - # in Errgonomic::Result::Ok, value + # in Ok(value) # "Measurement is #{value}" - # in Errgonomic::Result::Err, String => msg + # in Err(String => msg) # "Measurement failed with a message: #{msg}" - # in Errgonomic::Result::Err, Exception => e + # in Err(Exception => e) # "Measurement produced an exception -- #{e.class}: #{e}" # end # => "Measurement produced an exception -- StandardError: nope" def deconstruct - [self, value] + [value] end + protected + + # Sibling instances read each other's value for equality and ordering; + # nothing else does. + attr_reader :value + private def to_s_refusal @@ -491,7 +562,6 @@ def bounded_inspect(object = self) end def strict_equality!(other, operator) - return unless Errgonomic.strict_equality? return if other.is_a?(Errgonomic::Result::Any) raise Errgonomic::TypeMismatchError, @@ -512,8 +582,6 @@ def strict_equality_remedy(other) # The Ok variant. class Ok < Any - attr_accessor :value - # Ok is always ok # # @example @@ -540,21 +608,23 @@ def inspect end end - # The Err variant. + # The Err variant. It always carries an error, so unwrap_err! and a + # pattern variable bind what the caller put there. + # + # @example an Err without an error raises + # Err(:e).unwrap_err! # => :e + # Err() # => raise ArgumentError, "wrong number of arguments (given 0, expected 1)" + # Errgonomic::Result::Err.new # => raise ArgumentError, "wrong number of arguments (given 0, expected 1)" + # + # @example Err(nil) is an ordinary Err that holds nil + # Err(nil).inspect # => "Err(nil)" + # Err(nil).unwrap_err! # => nil + # Err(nil).deconstruct # => [nil] + # case Err(nil) + # in Ok(value) then [:ok, value] + # in Err(e) then [:err, e] + # end # => [:err, nil] class Err < Any - class Arbitrary; end - - attr_accessor :value - - # Err may be constructed without a value, if you want. - # - # @example - # Err(:y).value # => :y - # Err().value # => Arbitrary - def initialize(value = Arbitrary) - super(value) - end - # Err is always err # # @example @@ -571,15 +641,12 @@ def ok? false end - # Render like Rust's Debug; a value-less Err renders bare. + # Render like Rust's Debug, delegating to the inner value's inspect. # # @example # Err(:nope).inspect # => "Err(:nope)" - # Err().inspect # => "Err()" - # Errgonomic.with_strict_equality { Err(Some(1)).inspect } # => "Err(Some(1))" + # Err(Some(1)).inspect # => "Err(Some(1))" def inspect - return 'Err()' if value.equal?(Arbitrary) - "Err(#{value.inspect})" end end @@ -621,6 +688,11 @@ def Ok(value) end # Global convenience method for constructing an Err result. -def Err(value = Errgonomic::Result::Err::Arbitrary) +def Err(value) Errgonomic::Result::Err.new(value) end + +# The variants under their short names, so a pattern reads as it does in +# Rust: `in Ok(v)`, `in Err(e)`. +Errgonomic::VariantName.define(:Ok, Errgonomic::Result::Ok) +Errgonomic::VariantName.define(:Err, Errgonomic::Result::Err) diff --git a/lib/errgonomic/variant_name.rb b/lib/errgonomic/variant_name.rb new file mode 100644 index 0000000..999440d --- /dev/null +++ b/lib/errgonomic/variant_name.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Errgonomic + # What a bare `Some`, `None`, `Ok` or `Err` evaluates to. Rust writes a + # value as `return None`, where here the value is `None()`; the bare name + # is for patterns. It matches as the class it names, and refuses to become + # a string, so a missing pair of parentheses raises rather than writing a + # class name into a column. It is not a class, so an application's own + # `class None` fails where it is written instead of reopening the gem's. + # + # @example + # case Some(1) + # in Some(value) then value + # end # => 1 + # None === None() # => true + # None === Some(1) # => false + # None.inspect # => "Errgonomic::Option::None" + # "tier-#{None}" # => raise Errgonomic::SerializeError, "bare None names a variant for a pattern, not a value; build one with parentheses" + # [Ok].join # => raise Errgonomic::SerializeError, "bare Ok names a variant for a pattern, not a value; build one with parentheses" + # Some(1).is_a?(Some) # => raise TypeError, "class or module required" + # Some(1).is_a?(Errgonomic::Option::Some) # => true + class VariantName + # Name a variant at top level, refusing a constant the application + # already holds there rather than replacing it. + def self.define(name, variant) + if Object.const_defined?(name, false) + existing = Object.const_get(name) + return if existing.is_a?(VariantName) && existing.names?(variant) + + raise NameError.new("#{name} is already defined as #{existing.inspect}; errgonomic defines #{name} " \ + "at top level to name #{variant} in patterns, so rename the application's constant", name) + end + Object.const_set(name, new(name, variant)) + end + + def initialize(name, variant) + @name = name + @variant = variant + freeze + end + + def names?(variant) + @variant.equal?(variant) + end + + def ===(other) + @variant === other # rubocop:disable Style/CaseEquality + end + + def inspect + @variant.inspect + end + + # Refuse to stand in for a value. + def refuse!(*_args) + raise Errgonomic::SerializeError, + "bare #{@name} names a variant for a pattern, not a value; build one with parentheses" + end + alias to_s refuse! + alias to_json refuse! + alias as_json refuse! + end +end diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index db78023..497af54 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = '0.9.1' + VERSION = '0.10.2' end diff --git a/test/rails_test.rb b/test/rails_test.rb index d45ca2a..32dca4c 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -923,12 +923,12 @@ def test_a_wrapper_refuses_to_become_a_string def test_an_unmatched_case_in_over_a_wrapper_has_a_message_that_refuses error = assert_raises(NoMatchingPatternError) do case Some(1) - in Errgonomic::Result::Ok, v then v - in Errgonomic::Result::Err, e then e + in Ok(v) then v + in Err(e) then e end end assert_raises(Errgonomic::SerializeError) { error.message } - unmatched = 'case Some(1); in Errgonomic::Result::Ok, v then v; in Errgonomic::Result::Err, e then e; end' + unmatched = 'case Some(1); in Ok(v) then v; in Err(e) then e; end' _, printed, = run_ruby(unmatched) assert_match(/in '
': NoMatchingPatternError$/, printed) @@ -953,6 +953,163 @@ def test_a_tagged_logger_refuses_a_wrapper_that_a_plain_logger_inspects assert_equal "Some(1)\n[req-1] Some(1)\n", io.string end + # `return None` is how Rust spells a value, and in Ruby a bare variant name + # names the variant rather than building one. It refuses to become data + # wherever a wrapper does. + def test_a_bare_variant_name_refuses_to_become_a_string + [Some, None, Ok, Err].each do |variant| + assert_raises(Errgonomic::SerializeError) { variant.to_s } + assert_raises(Errgonomic::SerializeError) { "host-#{variant}.example" } + assert_raises(Errgonomic::SerializeError) { [variant].join(',') } + assert_raises(Errgonomic::SerializeError) { format('%s', variant) } + assert_raises(Errgonomic::SerializeError) { { tier: variant }.to_json } + assert_raises(Errgonomic::SerializeError) { { tier: variant }.as_json } + assert_raises(Errgonomic::SerializeError) { JSON.generate({ tier: variant }) } + end + end + + # A boolean column casts any object that is not false to true, and an + # integer column casts one without to_i to nil, so every boundary an + # Option is unwrapped at refuses a bare name before a column type sees it. + def test_a_bare_variant_name_refuses_a_column_write + note = Note.create!(title: 'Bare') + [Some, None, Ok, Err].each do |variant| + %i[pinned title body meta rank score price due_on read_at].each do |column| + assert_raises(Errgonomic::SerializeError, column.to_s) { Note.create!(column => variant) } + end + %i[pinned title rank].each do |column| + assert_raises(Errgonomic::SerializeError) { note.update!(column => variant) } + assert_raises(Errgonomic::SerializeError) { Note.update_all(column => variant) } + assert_raises(Errgonomic::SerializeError) { Note.insert_all([{ column => variant }]) } + assert_raises(Errgonomic::SerializeError) { Note.where(column => variant).to_a } + assert_raises(Errgonomic::SerializeError) { Note.where(column => [variant, 1]).to_a } + assert_raises(Errgonomic::SerializeError) { Note.find_by(column => variant) } + end + end + end + + def test_the_json_gem_alone_refuses_a_bare_variant_name + out, err, status = run_ruby(<<~CHILD, 'json') + [Some, None, Ok, Err].each do |variant| + puts({ tier: variant }.to_json) + rescue Errgonomic::SerializeError => e + puts e.class + end + CHILD + + assert status.success?, err + assert_equal ['Errgonomic::SerializeError'] * 4, out.lines(chomp: true) + end + + # A variant name still reads as the class it names wherever a pattern or a + # `case/when` asks it, and it inspects for a log line or a failure message. + def test_a_bare_variant_name_matches_and_inspects + matched = [Some(1), None(), Ok(Some(1)), Err(:x)].map do |subject| + case subject + in Some(v) then [:some, v] + in None then :none + in Ok(Some(v)) then [:ok_some, v] + in Err(e) then [:err, e] + end + end + assert_equal [[:some, 1], :none, [:ok_some, 1], %i[err x]], matched + assert_equal :err, (case Err(:x) when Ok then :ok when Err then :err end) + assert_equal 'Errgonomic::Option::None', None.inspect + failure = assert_raises(Minitest::Assertion) { assert_equal None, None() } + assert_includes failure.message, 'Errgonomic::Option::None' + assert_equal 'Errgonomic::Option::Some', Errgonomic::Option::Some.name + assert_equal 'Errgonomic::Result::Err', Errgonomic::Result::Err.inspect + end + + # An application's own class under a variant name must not reopen the + # gem's, nor be replaced by it, in either load order. + def test_an_application_constant_under_a_variant_name_is_refused + { 'class None; def label = "app none"; end' => 'None is not a class', + 'module Ok; end' => 'Ok is not a module' }.each do |definition, complaint| + _, err, status = run_ruby(definition) + refute status.success?, definition + assert_includes err, complaint + end + + { 'class Ok; def self.status = 200; end' => 'Ok', + 'Some = Struct.new(:value)' => 'Some' }.each do |definition, name| + _, err, status = run_ruby("#{definition}; require 'errgonomic'", preload: false) + refute status.success?, definition + assert_includes err, "#{name} is already defined" + end + end + + # The gem loaded twice, through two paths to the same file, meets its own + # constants and keeps them. + def test_loading_the_gem_twice_keeps_its_variant_names + _, err, status = run_ruby("load 'errgonomic/option.rb'; load 'errgonomic/result.rb'") + assert status.success?, err + end + + # Which collection operations raise on a cross-type comparison depends on + # how they compare and on which side holds the wrapper. The README lists + # them; this pins every case it names. + def test_the_strict_equality_surface_is_the_one_the_readme_lists + long = (100..120).to_a + tc = Class.new(Minitest::Test).new('surface') + one = 1 + ints = [1] + strings = { a: 'a' } + surface = { + # Pairwise ==, with the wrapper as the member asked. + '[Some(1)].include?(1)' => [:raises, -> { [Some(1)].include?(1) }], + '[Some(1)].index(1)' => [:raises, -> { [Some(1)].index(1) }], + '[Some(1)].delete(1)' => [:raises, -> { [Some(1)].delete(1) }], + '[Some(1)].count(1)' => [:raises, -> { [Some(1)].count(1) }], + '[Some(1)] == [1]' => [:raises, -> { [Some(1)] == [1] }], + '{ a: Some(1) } == { a: 1 }' => [:raises, -> { { a: Some(1) } == { a: 1 } }], + 'case 1 when Some(1)' => [:raises, -> { case one when Some(1) then :hit end }], + 'assert_equal Some(1), 1' => [:raises, -> { tc.assert_equal Some(1), 1 }], + # Pairwise ==, with the wrapper on the other side: an Integer hands + # the comparison back, a String, a Symbol or nil answers for itself. + '[1].include?(Some(1))' => [:raises, -> { [1].include?(Some(1)) }], + '[1] == [Some(1)]' => [:raises, -> { ints == [Some(1)] }], + 'case Some(1) when 1' => [:raises, -> { case Some(1) when 1 then :hit end }], + 'assert_equal 1, Some(1)' => [:raises, -> { tc.assert_equal 1, Some(1) }], + "['a'].include?(Some('a'))" => [:quiet, -> { ['a'].include?(Some('a')) }], + '[:a].index(Some(:a))' => [:quiet, -> { [:a].index(Some(:a)) }], + '[nil].count(None())' => [:quiet, -> { [nil].count(None()) }], + "{ a: 'a' } == { a: Some('a') }" => [:quiet, -> { strings == { a: Some('a') } }], + "case Some('a') when 'a'" => [:quiet, -> { case Some('a') when 'a' then :hit end }], + "assert_equal 'a', Some('a')" => [:fails, -> { tc.assert_equal 'a', Some('a') }], + # Pairwise eql? on short arrays: only the wrapper's own eql? raises. + '[Some(1)] - [1]' => [:raises, -> { [Some(1)] - [1] }], + '[1] - [Some(1)]' => [:quiet, -> { [1] - [Some(1)] }], + '[Some(1)] & [1]' => [:raises, -> { [Some(1)] & [1] }], + '[1] & [Some(1)]' => [:quiet, -> { [1] & [Some(1)] }], + '[1] | [Some(1)]' => [:raises, -> { [1] | [Some(1)] }], + '[Some(1)] | [1]' => [:quiet, -> { [Some(1)] | [1] }], + # Past the cutoff they hash: both arrays for -, either one for & and |. + 'long - short' => [:raises, -> { ([Some(1)] + long) - [1] }], + 'long - long' => [:quiet, -> { ([Some(1)] + long) - ([1] + long) }], + 'long & short' => [:quiet, -> { ([Some(1)] + long) & [1] }], + 'long | short' => [:quiet, -> { ([1] + long) | [Some(1)] }], + # Hashing, whichever side holds the wrapper. + '{ Some(1) => :v }[1]' => [:quiet, -> { { Some(1) => :v }[1] }], + '{ 1 => :v }[Some(1)]' => [:quiet, -> { { 1 => :v }[Some(1)] }], + 'Set[Some(1)].include?(1)' => [:quiet, -> { Set[Some(1)].include?(1) }], + 'Set[1].include?(Some(1))' => [:quiet, -> { Set[1].include?(Some(1)) }], + '[Some(1), 1].uniq' => [:quiet, -> { [Some(1), 1].uniq }], + '[1, Some(1)].uniq' => [:quiet, -> { [1, Some(1)].uniq }], + '[Some(1), 1].group_by(&:itself)' => [:quiet, -> { [Some(1), 1].group_by(&:itself) }] + } + measured = surface.transform_values do |(_, call)| + call.call + :quiet + rescue Errgonomic::TypeMismatchError + :raises + rescue Minitest::Assertion + :fails + end + + assert_equal surface.transform_values(&:first), measured + end + # ActionView's output buffer appends a value through to_s, so a bare # <%= reader %> of a Some raises rather than shipping Some("...") # to a page; the template wraps the refusal as its cause. A None answers @@ -2251,10 +2408,10 @@ def render_note_form(record) end end - # Run a script in a fresh Ruby that loads errgonomic and the named - # libraries and nothing of this process's Rails. - def run_ruby(script, *libraries) - requires = [*libraries, 'errgonomic'].map { |library| "-r#{library}" } + # Run a script in a fresh Ruby that loads the named libraries, errgonomic + # unless told not to, and nothing of this process's Rails. + def run_ruby(script, *libraries, preload: true) + requires = [*libraries, *('errgonomic' if preload)].map { |library| "-r#{library}" } Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), *requires, '-e', script) end diff --git a/test/support/strict_equality.rb b/test/support/strict_equality.rb deleted file mode 100644 index 45687bb..0000000 --- a/test/support/strict_equality.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -# The Rails integration suite with cross-type equality raising, so a -# comparison between a wrapper and a plain value anywhere under ActiveRecord -# fails the run rather than quietly answering false. -require_relative '../../lib/errgonomic' - -Errgonomic.strict_equality = true - -require_relative '../rails_test'