From 5fcd9be53090c540996bfff2c7030483b8e880fa Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:50:05 -0500 Subject: [PATCH 01/11] Keep presence as the Rails spelling of unwrap_or(nil) `presence` on an Option answers the inner value of a Some and nil for a None, which is exactly what unwrap_or(nil) says and what a Rails developer already reaches for, so it is supported rather than nudged toward a combinator. It follows the discriminant like every other presence question here: Some("") answers "" where Object#presence answers nil. The nudge from the remaining soft-deprecated spellings now fires once per process per method. A soft deprecation is a message to a developer, and one per call turns a hot path into a stderr flood. --- CHANGELOG.md | 2 ++ README.md | 4 +++- lib/errgonomic/option.rb | 42 +++++++++++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f2e84..40194f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [Unreleased] +- `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil` +- The nudge from the soft-deprecated `present_or`, `present_or_else` and `present_or_raise!` fires once per process per method rather than once per call, so a hot path no longer floods stderr - `ActiveRecordOptional` installs its wrapped readers into a per-class module, so a model's own reader of the same name composes with the wrapper through `super` instead of one silently replacing the other - A wrapped reader lifts a value exactly one layer: an Option arriving from beneath the wrapper passes through instead of being wrapped a second time - `belongs_to` and `has_one` writers accept an Option: `Some(record)` assigns the record it wraps and `None()` clears the association, so a wrapped reader can be assigned straight onto another record diff --git a/README.md b/README.md index feeb035..9012ca4 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,9 @@ Truthiness is the Rails reflex that breaks. An Option is an object, so `None()` Writers unwrap under that integration, which changes what a truthiness slip costs rather than removing it. `self.isbn = isbn || 'unassigned'` no longer leaks a wrapper into the database; it silently persists whatever `isbn` held, `nil` included, because a `None` is truthy and the fallback is never reached. The write succeeds and nothing raises. `unwrap_or('unassigned')` is the spelling that means it. -The presence helpers are soft-deprecated on Options in favor of the combinators. The present side unwraps, where on any other object it returns the receiver — `Some(v).present_or_raise!(msg)`, `present_or(default)`, `present_or_else { }`, and `presence` all yield `v`, and `None` raises, substitutes, or answers `nil` — and each call prints a one-line stderr nudge naming the combinator to use instead (`expect!`, `unwrap_or`, `unwrap_or_else`, `unwrap_or(nil)`). The blank side (`blank_or*`) raises `UnwrappedAccessError` outright: an Option's blankness is its discriminant, so test it with `none?`. +`presence` is the Rails spelling of `unwrap_or(nil)`, and it is supported: `Some(x).presence` is `x` and `None().presence` is `nil`, so `isbn.presence || 'unassigned'` reaches the value rather than the wrapper. It follows the discriminant, as every presence question on an Option does, so `Some("").presence` is `""` where `"".presence` on any other object is `nil`. An Option's presence is whether it holds a value, not what that value amounts to; unwrap first (`isbn.unwrap_or("").presence`) to ask the inner value's own presence. + +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?`. 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 }`). diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index ea4d05a..bb9480e 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'set' +require 'stringio' + module Errgonomic module Option # The base class for all options. Some and None are subclasses. @@ -20,6 +23,11 @@ class Any is_none_or: :none_or? }.freeze + # Names already nudged about. A soft deprecation is a message to a + # developer, and one per process says it; one per call turns a hot + # path into a stderr flood. + NUDGED = Set.new + # An Option deliberately forwards nothing to its inner value, so a miss # here is almost always someone treating the container as its contents. # Teach the route out instead of leaving a bare NoMethodError. Rust @@ -201,10 +209,11 @@ def blank? # The presence helpers on Object keep their receiver; on an Option that # would hand back the wrapper where the caller asked for a value. Here # the present side unwraps instead, so a name that reads like an - # accessor behaves like one. The whole family is soft-deprecated on - # Options in favor of the combinators, so each call nudges via stderr, - # and the blank side, which has no working call sites to preserve, - # teaches rather than guesses at semantics. + # accessor behaves like one. The +_or+ spellings are soft-deprecated on + # Options in favor of the combinators and nudge via stderr; `presence` + # is the Rails idiom for unwrap_or(nil) and stays. The blank side, which + # has no working call sites to preserve, teaches rather than guesses at + # semantics. # Returns the inner value of a Some, and raises on a None. Presence # follows the discriminant, so Some(nil) yields nil. @@ -235,6 +244,15 @@ def present_or_raise!(message) # @example # Some("secret").present_or("fallback") # => "secret" # None().present_or("fallback") # => "fallback" + # + # @example the nudge fires once per process, so a hot path stays quiet + # Some(1).present_or(2) + # nudges = StringIO.new + # original = $stderr + # $stderr = nudges + # Some(1).present_or(2) + # $stderr = original + # nudges.string # => "" def present_or(default) presence_nudge('present_or', 'unwrap_or') return default if none? @@ -260,15 +278,27 @@ def present_or_else(&block) # Returns the inner value of a Some, and nil on a None, so the Rails # +presence || default+ idiom reaches the value rather than the wrapper. + # Presence follows the discriminant, so a blank inner value is still a + # value: Some("").presence is "", where Object#presence answers nil. # # @return [Object, nil] The inner value of a Some, otherwise nil. # # @example # Some("secret").presence # => "secret" + # Some("").presence # => "" # None().presence # => nil # None().presence || "fallback" # => "fallback" + # + # @example the Rails spelling of unwrap_or(nil), and no nudge with it + # nudges = StringIO.new + # original = $stderr + # $stderr = nudges + # captured = Some("").presence + # None().presence + # $stderr = original + # captured # => "" + # nudges.string # => "" def presence - presence_nudge('presence', 'unwrap_or(nil)') return nil if none? value @@ -629,6 +659,8 @@ def xor(other) private def presence_nudge(from, to) + return unless NUDGED.add?(from) + warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`." end From 335587bc8abbfed2dc7198c9cea13c0b9381c71b Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:51:26 -0500 Subject: [PATCH 02/11] Render an Option or a Result from to_s to_s answers what inspect answers: "Some(1)", "None", "Ok(1)", "Err(:x)". Rust gives these types a Debug and no Display, so refusing was a faithful reading, but a to_s that raises replaces the real exception while a rescue builds its log line, which is the worst place to be strict about a wrapper. The rendered form leaks nothing silently: a Some(1) in a log says plainly that a wrapper arrived where a value was meant. to_json and as_json still raise, because a payload has no such tell. --- CHANGELOG.md | 1 + README.md | 6 ++++-- lib/errgonomic/option.rb | 13 +++++++++---- lib/errgonomic/result.rb | 14 +++++++++----- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40194f3..b26fe60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise - `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil` - The nudge from the soft-deprecated `present_or`, `present_or_else` and `present_or_raise!` fires once per process per method rather than once per call, so a hot path no longer floods stderr - `ActiveRecordOptional` installs its wrapped readers into a per-class module, so a model's own reader of the same name composes with the wrapper through `super` instead of one silently replacing the other diff --git a/README.md b/README.md index 9012ca4..362702a 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ in Errgonomic::Option::None end ``` -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 `#` to a user. 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). +An unhandled Option refuses to leak into your output: `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#` to a user. 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). + +`to_s` renders rather than refusing: `Some(1).to_s` is `"Some(1)"` and `None().to_s` is `"None"`, matching `inspect`, and the same holds for `Ok` and `Err`. Rust gives `Option` a `Debug` and no `Display`, so raising was the faithful reading, but a `to_s` that raises replaces the real exception while a `rescue` builds its log line, which is the worst possible place to be strict. The rendered form is unambiguous: a `Some(1)` in a log says a wrapper arrived where a value was meant. `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap. @@ -160,7 +162,7 @@ in Errgonomic::Result::Err, Exception => e end ``` -Like Options, unwrapped Results refuse `to_s`, `to_json`, and `as_json`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. +Like Options, unwrapped Results refuse `to_json` and `as_json`, and render `to_s` as `inspect` does. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. ### Optional collections diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index bb9480e..f5f8c09 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -573,13 +573,18 @@ def zip_with(other, &block) Some(other) end - # Refuse to serialize an unwrapped Option as a String. Options must be - # correctly handled to access their inner value. + # Render as inspect does. Rust gives Option a Debug and no Display, so + # refusing was faithful, but a to_s that raises replaces the real + # exception while a rescue builds its log line, and the rendered form + # says plainly that a wrapper arrived where a value was meant. # # @example - # None().to_s # => raise Errgonomic::SerializeError, "cannot serialize an unwrapped Option" + # Some(1).to_s # => "Some(1)" + # Some("x").to_s # => "Some(\"x\")" + # None().to_s # => "None" + # "value: #{Some(1)}" # => "value: Some(1)" def to_s - raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option' + inspect end # Refuse to serialize an unwrapped Option as JSON. Not only should we diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 949ad9c..b3469a6 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -347,14 +347,18 @@ def map_err(&block) Err(block.call(value)) end - # Refuse to serialize an unwrapped Result as a String. Results must be - # correctly handled to access their inner value. + # Render as inspect does. Rust gives Result a Debug and no Display, so + # refusing was faithful, but a to_s that raises replaces the real + # exception while a rescue builds its log line, and the rendered form + # says plainly that a wrapper arrived where a value was meant. # # @example - # Ok("").to_s # => raise Errgonomic::SerializeError, "cannot serialize an unwrapped Result" - # Err("").to_s # => raise Errgonomic::SerializeError, "cannot serialize an unwrapped Result" + # Ok(1).to_s # => "Ok(1)" + # Err(:nope).to_s # => "Err(:nope)" + # Err().to_s # => "Err()" + # "outcome: #{Ok(1)}" # => "outcome: Ok(1)" def to_s - raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result' + inspect end # Refuse to serialize an unwrapped Result as JSON. Not only should we From 679ef642fcd6412500ace235a8651c290a43cdcd Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:52:15 -0500 Subject: [PATCH 03/11] Build an expect! message lazily from a block A message that names what is missing usually interpolates the thing that is absent, and an eager argument pays for that on every call that succeeds. A block is called only on the branch that raises, so `tier.expect! { "no tier for #{account.id}" }` costs nothing on the happy path. present_or_raise! takes one on the same terms, and the positional message keeps working. --- CHANGELOG.md | 1 + README.md | 2 ++ lib/errgonomic/option.rb | 20 +++++++++++++------- lib/errgonomic/result.rb | 8 +++++--- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b26fe60..048c9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged - [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise - `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil` - The nudge from the soft-deprecated `present_or`, `present_or_else` and `present_or_raise!` fires once per process per method rather than once per call, so a hot path no longer floods stderr diff --git a/README.md b/README.md index 362702a..297f4bf 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ An unhandled Option refuses to leak into your output: `to_json` and `as_json` ra `to_s` renders rather than refusing: `Some(1).to_s` is `"Some(1)"` and `None().to_s` is `"None"`, matching `inspect`, and the same holds for `Ok` and `Err`. Rust gives `Option` a `Debug` and no `Display`, so raising was the faithful reading, but a `to_s` that raises replaces the real exception while a `rescue` builds its log line, which is the worst possible place to be strict. The rendered form is unambiguous: a `Some(1)` in a log says a wrapper arrived where a value was meant. +`expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged. + `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap. Presence follows the discriminant, as in Rust: `Some` is `present?` and `None` is `blank?`, regardless of the wrapped value. So `Some(false).present?` and `Some(nil).present?` are both `true`. If you care about the inner value's own presence, unwrap it first. diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index f5f8c09..c74505e 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -216,7 +216,8 @@ def blank? # semantics. # Returns the inner value of a Some, and raises on a None. Presence - # follows the discriminant, so Some(nil) yields nil. + # follows the discriminant, so Some(nil) yields nil. A block is called + # only on the None branch, as it is for expect!. # # @param message [String] The error message to raise on a None. # @return [Object] The inner value of a Some. @@ -225,9 +226,10 @@ def blank? # Some("secret").present_or_raise!("no secret") # => "secret" # Some(nil).present_or_raise!("no secret") # => nil # None().present_or_raise!("no secret") # => raise Errgonomic::NotPresentError, "no secret" - def present_or_raise!(message) + # None().present_or_raise! { "no secret for #{7}" } # => raise Errgonomic::NotPresentError, "no secret for 7" + def present_or_raise!(message = nil, &block) presence_nudge('present_or_raise', 'expect!') - raise Errgonomic::NotPresentError, message if none? + raise Errgonomic::NotPresentError, block ? block.call : message if none? value end @@ -356,13 +358,17 @@ def unwrap! value end - # returns the inner value if pressent, else raises an error with the given - # message + # Returns the inner value of a Some, else raises with the given message. + # A block is called only on the None branch, so a message that + # interpolates costs nothing on the path that succeeds. + # # @example # Some(1).expect!("msg") # => 1 # None().expect!("here's why this failed") # => raise Errgonomic::ExpectError, "here's why this failed" - def expect!(msg) - raise Errgonomic::ExpectError, msg if none? + # Some(1).expect! { "built only where it is raised" } # => 1 + # None().expect! { "no tier for #{7}" } # => raise Errgonomic::ExpectError, "no tier for 7" + def expect!(msg = nil, &block) + raise Errgonomic::ExpectError, block ? block.call : msg if none? value end diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index b3469a6..f731aae 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -169,15 +169,17 @@ def unwrap! end # Return the inner value of an Ok, else raise an exception with the given - # message when Err. + # message when Err. A block is called only on the Err branch, so a + # message that interpolates costs nothing on the path that succeeds. # # @param msg [String] # # @example # Ok(1).expect!("should have worked") # => 1 # Err(:d).expect!("should have worked") # => raise Errgonomic::ExpectError, "should have worked" - def expect!(msg) - raise Errgonomic::ExpectError, msg unless ok? + # Err(:d).expect! { "no rate for #{7}" } # => raise Errgonomic::ExpectError, "no rate for 7" + def expect!(msg = nil, &block) + raise Errgonomic::ExpectError, block ? block.call : msg unless ok? @value end From a6b8f50d6d62c9534cc19055ab1cc96a04da138e Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:56:10 -0500 Subject: [PATCH 04/11] Add opt-in strict equality for cross-type comparison A wrapper compared to a plain value answers false, quietly, because that is what every Ruby object does across types and because raising would break Array#include?, assertion diffs and dirty tracking. Errgonomic.strict_equality = true makes ==, != and eql? raise a TypeMismatchError instead, naming both classes and the spelling to reach for, and nil counts as another type so None() == nil points at none?. Two Options still compare, and hash is untouched, so an Option stays a usable Hash key. It is meant for a test suite: rake test:strict runs this gem's Rails integration suite with it on, and nothing under ActiveRecord compares a wrapper cross-type today. --- .github/workflows/main.yml | 2 ++ CHANGELOG.md | 1 + CONTRIBUTING.md | 3 +- README.md | 25 ++++++++++++++++- Rakefile | 11 +++++++- lib/errgonomic.rb | 27 ++++++++++++++++++ lib/errgonomic/option.rb | 49 +++++++++++++++++++++++++++++++++ lib/errgonomic/result.rb | 37 ++++++++++++++++++++++++- test/support/strict_equality.rb | 10 +++++++ 9 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 test/support/strict_equality.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 69b76cb..eb7e37e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,3 +29,5 @@ 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 048c9b2..3ecc831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on - `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged - [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise - `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1184dfe..c31ff1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,7 @@ 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. @@ -42,7 +43,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` and `rake test` 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`, `rake test` and `rake test:strict` 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/README.md b/README.md index 297f4bf..b5b0e9f 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ 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?`. -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 }`). +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). ### Result @@ -236,6 +236,29 @@ 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: + +```ruby +Errgonomic.strict_equality = true + +Some(5) == 5 # => raises Errgonomic::TypeMismatchError +Some(5) != 5 # => raises +Some(5).eql?(5) # => raises +None() == nil # => raises, pointing at none? +Ok(1) == 1 # => raises +Some(5) == Some(5) # => true, as always +``` + +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. 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: + +```ruby +Errgonomic.with_strict_equality do + assert_equal Some(5), book.pages +end +``` + +This gem runs its own Rails integration suite that way, as `rake test:strict`. + ### Rails integration When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord: diff --git a/Rakefile b/Rakefile index a72214c..6d7dd4b 100644 --- a/Rakefile +++ b/Rakefile @@ -16,7 +16,16 @@ YARD::Doctest::RakeTask.new do |task| task.pattern = FileList['lib/**/*.rb'].join(' ') end -task default: %i[test yard:doctest] +namespace :test do + desc 'Run the Rails integration suite with strict equality on' + task :strict do + ruby '-Ilib -Itest test/support/strict_equality.rb' + 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] namespace :gems4nix do desc 'Regenerate gem-groups.json after Gemfile/Gemfile.lock changes' diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 11a75d1..1d41b1e 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -86,6 +86,33 @@ def self.with_ambiguous_downstream_errors @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? diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index c74505e..b46fcd9 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -84,7 +84,35 @@ def respond_to_missing?(name, include_private = 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.class + # end + # end # => Errgonomic::TypeMismatchError + # Errgonomic.with_strict_equality { Some(5) == Some(5) } # => true + # Errgonomic.with_strict_equality { Some(5) == None() } # => false + # + # @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?") + # end + # end # => true def ==(other) + strict_equality!(other, '==') return false if self.class != other.class return true if none? @@ -102,7 +130,18 @@ def ==(other) # None().eql?(None()) # => true # { 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 def eql?(other) + strict_equality!(other, 'eql?') return false if self.class != other.class return true if none? @@ -675,6 +714,16 @@ def presence_nudge(from, to) warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`." end + def strict_equality!(other, operator) + return unless Errgonomic.strict_equality? + return if other.is_a?(Errgonomic::Option::Any) + + raise Errgonomic::TypeMismatchError, <<~MSG + #{self.class} #{operator} #{other.class} compares an Option to a value that is not one, which strict equality refuses. + #{other.nil? ? 'Absence here is the discriminant: ask none?, or nil? under the Rails integration.' : "Compare Options (opt == Some(#{other.inspect})), test the inner value (opt.some_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first."} + MSG + end + def raise_blank_side_teaching(name) raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name) `#{name}` is not supported on an Option, whose blankness is its discriminant. diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index f731aae..2560ba6 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -87,7 +87,18 @@ def respond_to_missing?(name, include_private = 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 + # end # => Errgonomic::TypeMismatchError + # Errgonomic.with_strict_equality { Ok(1) == Ok(1) } # => true def ==(other) + strict_equality!(other, '==') return false if self.class != other.class value == other.value @@ -103,7 +114,18 @@ def ==(other) # Ok(1).eql?(Err(1)) # => false # { 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 def eql?(other) + strict_equality!(other, 'eql?') self.class == other.class && value.eql?(other.value) end @@ -412,6 +434,18 @@ def pretty_print(pp) def deconstruct [self, value] end + + private + + def strict_equality!(other, operator) + return unless Errgonomic.strict_equality? + return if other.is_a?(Errgonomic::Result::Any) + + raise Errgonomic::TypeMismatchError, <<~MSG + #{self.class} #{operator} #{other.class} compares a Result to a value that is not one, which strict equality refuses. + Compare Results (res == Ok(#{other.inspect})), test the inner value (res.ok_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first. + MSG + end end # The Ok variant. @@ -480,8 +514,9 @@ def ok? # @example # Err(:nope).inspect # => "Err(:nope)" # Err().inspect # => "Err()" + # Errgonomic.with_strict_equality { Err(Some(1)).inspect } # => "Err(Some(1))" def inspect - return 'Err()' if value == Arbitrary + return 'Err()' if value.equal?(Arbitrary) "Err(#{value.inspect})" end diff --git a/test/support/strict_equality.rb b/test/support/strict_equality.rb new file mode 100644 index 0000000..45687bb --- /dev/null +++ b/test/support/strict_equality.rb @@ -0,0 +1,10 @@ +# 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' From 3932f23fdce204d8ac90c3f5f10dcee26f25be56 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:58:43 -0500 Subject: [PATCH 05/11] Send try to the value inside an Option ActiveSupport's Object#try asks respond_to? first, and an Option answers false for anything it does not define, so try on a wrapper was a quiet nil for every method and a block was handed the wrapper rather than the value. Both forms now reach the value: a Some tries what it holds, a None answers nil, and a method the value does not have is nil as it is for any other receiver. try! is the strict variant, raising for that. They live with the rest of the ActiveSupport-dependent patches, so a checkout without Rails is unchanged. --- CHANGELOG.md | 1 + README.md | 2 + .../rails/active_record_optional.rb | 35 ++++++++++++++ test/rails_test.rb | 47 +++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ecc831..1ab2e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it - `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on - `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged - [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise diff --git a/README.md b/README.md index b5b0e9f..cdade33 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,8 @@ It is available on every model, converted or not, because it lifts both ends one `Object#to_option` lifts any value into an Option (`nil.to_option # => None()`). It lifts once and only once: an Option passes through unchanged (`Some(1).to_option # => Some(1)`), so lifting a value whose provenance you do not know is safe. That is the rule everywhere in the integration. An ActiveRecord attribute or association is never an optional of an optional, so a wrapped reader never nests a second Option around a value that already is one. Nesting is invisible until something reaches for the inner value, which is the ambiguous failure the type exists to prevent. +`try` reaches the value inside the Option: `book.isbn.try(:strip)` strips the ISBN and answers `nil` where there is none, and the block form yields the value (`book.isbn.try { |isbn| isbn.strip }`). ActiveSupport's `Object#try` asks `respond_to?` first, which an Option answers `false` to for anything it does not define, so without this it would be a quiet `nil` for every method and would hand a block the wrapper rather than the value. A method the value does not have is still `nil`, as it is for any other receiver, and `try!` is Rails' strict variant, which raises for that and still answers `nil` for a `None`. Both are defined only under this integration, where ActiveSupport's `try` is what they follow. + #### ActiveRecord compromises 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: diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 691d6d4..ec6717b 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -355,6 +355,41 @@ class Any def to_option self end + + # ActiveSupport's Object#try asks respond_to?, which an Option answers + # false for anything it does not define, so try on a wrapper would be a + # quiet nil for every method. Send it to the value instead: a Some + # tries what it holds, a None is absent and answers nil, and a method + # the value does not have is nil as it is for any other receiver. + # + # @example + # Some("bob").try(:upcase) # => "BOB" + # Some("bob").try(:no_such_method) # => nil + # None().try(:upcase) # => nil + # Some(2).try { |pages| pages * 3 } # => 6 + # None().try { |pages| pages * 3 } # => nil + def try(...) + return nil if none? + + value.try(...) + end + + # Rails' strict variant: absence is still nil, a method the value does + # not have raises. + # + # @example + # Some("bob").try!(:upcase) # => "BOB" + # None().try!(:upcase) # => nil + # begin + # Some("bob").try!(:no_such_method) + # rescue NoMethodError => e + # e.class + # end # => NoMethodError + def try!(...) + return nil if none? + + value.try!(...) + end end end end diff --git a/test/rails_test.rb b/test/rails_test.rb index 9a80269..eb33cc6 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -1892,6 +1892,53 @@ def test_validating_a_converted_record_with_no_validations_does_not_raise assert_predicate Note.new, :valid? end + # ActiveSupport's Object#try asks respond_to?, which an Option answers + # false for anything it does not define, so try on a wrapper is a silent + # nil unless the Option sends it to the value it holds. + def test_try_reaches_the_value_inside_a_some + book = Book.create!(title: 'The Dark Forest', isbn: ' 9780765377081 ') + + assert_equal '9780765377081', book.isbn.try(:strip) + end + + # Rails' try answers nil for a method the value does not have, and so does + # this one. + def test_try_answers_nil_for_a_method_the_value_does_not_have + book = Book.create!(title: 'The Dark Forest', isbn: '9780765377081') + + assert_nil book.isbn.try(:no_such_method) + end + + def test_try_on_a_none_is_nil + book = Book.create!(title: 'The Dark Forest') + + assert_nil book.isbn.try(:strip) + assert_nil(book.isbn.try { |isbn| isbn.to_s.strip }) + end + + def test_try_yields_the_value_to_a_block + assert_equal 'THE DARK FOREST', Some('The Dark Forest').try(&:upcase) + assert_equal(6, Some(2).try { |pages| pages * 3 }) + end + + def test_try_forwards_arguments_and_keywords_to_the_value + greeter = Class.new do + def greeting(salutation, punctuation: '.') + "#{salutation}, reader#{punctuation}" + end + end.new + + assert_equal 'Hello, reader!', Some(greeter).try(:greeting, 'Hello', punctuation: '!') + end + + # try! is Rails' strict variant: absence is still nil, a missing method is + # not. + def test_try_bang_raises_where_try_answers_nil + assert_equal 'BOB', Some('bob').try!(:upcase) + assert_nil None().try!(:upcase) + assert_raises(NoMethodError) { Some('bob').try!(:no_such_method) } + end + private def declare_serialize_none(mode, **scope) From e6d628a8308b5c5326ae69ea6e2d44d92ec5923b Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:59:11 -0500 Subject: [PATCH 06/11] Say that map nests an Option its block returns map wraps whatever the block hands back, which is Rust's semantics and stays. The nesting is invisible until something reaches for the inner value, so the README and the method now say it outright and name and_then as the spelling for a block that returns an Option. The docstring also dropped a pedantic runtime check it describes and does not do. --- CHANGELOG.md | 1 + README.md | 2 ++ lib/errgonomic/option.rb | 8 ++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab2e9f..0569481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had - `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it - `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on - `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged diff --git a/README.md b/README.md index cdade33..0a257e5 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ Some(1).ok_or("nope") # => Ok(1) None().ok_or("nope") # => Err("nope") ``` +`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: ```ruby diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index b46fcd9..198fc88 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -454,12 +454,16 @@ def tap_some(&block) end # Maps the Option to another Option by applying a function to the - # contained value (if Some) or returns None. Raises a pedantic exception - # if the return value of the block is not an Option. + # contained value (if Some) or returns None. Whatever the block returns + # is wrapped, as in Rust: a block that returns an Option gives + # Some(Some(x)). and_then is the spelling for a block that returns an + # Option. # # @example # Some(1).map { |x| x + 1 } # => Some(2) # None().map { |x| x + 1 } # => None() + # Some(1).map { |x| Some(x + 1) } # => Some(Some(2)) + # Some(1).and_then { |x| Some(x + 1) } # => Some(2) def map(&block) return self if none? From fe61c3b60457b3fae5b44118403e1d6b5b8ec115 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 20:59:58 -0500 Subject: [PATCH 07/11] Answer map_or with the bare value Rust gives map_or and map_or_else are the exit from an Option: they take a default for the absent case precisely so the caller can stop handling one. Both wrapped their answer, so a caller got Some(4) where Rust gives 4 and had to unwrap the thing it had just supplied a default for. They now hand back what the default or the block gives. map is the combinator that stays inside the Option. This is a breaking change for anything reading their return value. --- CHANGELOG.md | 1 + README.md | 2 ++ lib/errgonomic/option.rb | 33 ++++++++++++++++----------------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0569481..393b18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- [Behavior change] `Option#map_or` and `Option#map_or_else` answer the bare value their default or block gives, as Rust's do, where 0.8.x wrapped it: `Some(2).map_or(0) { |v| v * 2 }` is `4` and `None().map_or(0) { }` is `0`. They are the exit from the Option, where `map` stays inside it. `Result` has no `map_or` to correct - [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had - `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it - `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on diff --git a/README.md b/README.md index 0a257e5..93ba93f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ Some(1).expect!("must be set") # => 1 Some(1).map { |x| x + 1 } # => Some(2) Some(2).and_then { |x| Some(x + 1) } # => Some(3) +Some(2).map_or(0) { |x| x * 2 } # => 4, a bare value +None().map_or(0) { |x| x * 2 } # => 0 None().or(Some(1)) # => Some(1) Some(:left).xor(None()) # => Some(:left) Some(1).zip(Some(2)) # => Some([1, 2]) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 198fc88..5a8a9c7 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -470,34 +470,33 @@ def map(&block) Some(block.call(value)) end - # Returns the provided default (if none), or applies a function to the - # contained value (if some). If you want lazy evaluation for the provided - # value, use +map_or_else+. + # Returns the provided default (if none), or the block applied to the + # contained value (if some). Both come back bare, as Rust's map_or + # gives: this is the exit from the Option, where map stays inside it. + # Use +map_or_else+ when the default is expensive to build. # # @example - # None().map_or(1) { 100 } # => Some(1) - # Some(1).map_or(100) { |x| x + 1 } # => Some(2) - # Some("foo").map_or(0) { |str| str.length } # => Some(3) + # None().map_or(1) { 100 } # => 1 + # Some(1).map_or(100) { |x| x + 1 } # => 2 + # Some("foo").map_or(0) { |str| str.length } # => 3 + # Some(2).map_or(0) { |x| x * 2 } # => 4 def map_or(default, &block) - return Some(default) if none? + return default if none? - Some(block.call(value)) + block.call(value) end # Computes a default from the given Proc if None, or applies the block to - # the contained value (if Some). + # the contained value (if Some). Both come back bare, as map_or's do. # # @example - # None().map_or_else(-> { :foo }) { :bar } # => Some(:foo) - # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => Some(3) - # None().map_or_else( -> { nil }) { |str| str.length } # => None() + # None().map_or_else(-> { :foo }) { :bar } # => :foo + # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3 + # None().map_or_else(-> { nil }) { |str| str.length } # => nil def map_or_else(proc, &block) - if none? - val = proc.call - return val ? Some(val) : None() - end + return proc.call if none? - Some(block.call(value)) + block.call(value) end # convert the option into a result where Some is Ok and None is Err From 6fdb4ca3af74ece5f4f7cf7f5966731610b46698 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 21:01:03 -0500 Subject: [PATCH 08/11] Iterate an Option with each An Option is a collection of zero or one, and each says so: a Some yields its value once, a None yields nothing, and without a block you get an Enumerator, which is where the rest of Enumerable lives. Enumerable itself stays out. filter and first are already Option's own and answer Options; Enumerable's answer plain values, and one name cannot mean both. --- CHANGELOG.md | 1 + README.md | 5 +++++ lib/errgonomic/option.rb | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 393b18c..0d34cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- `Option#each` yields the inner value once for a `Some` and not at all for a `None`, and answers an Enumerator without a block, so an Option reads as the zero-or-one collection it is. `Enumerable` is deliberately not included: its `filter`, `select` and `first` answer plain values where an Option's own answer Options - [Behavior change] `Option#map_or` and `Option#map_or_else` answer the bare value their default or block gives, as Rust's do, where 0.8.x wrapped it: `Some(2).map_or(0) { |v| v * 2 }` is `4` and `None().map_or(0) { }` is `0`. They are the exit from the Option, where `map` stays inside it. `Result` has no `map_or` to correct - [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had - `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it diff --git a/README.md b/README.md index 93ba93f..c138b97 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,13 @@ Some(:left).xor(None()) # => Some(:left) Some(1).zip(Some(2)) # => Some([1, 2]) Some(1).ok_or("nope") # => Ok(1) None().ok_or("nope") # => Err("nope") + +Some(1).each { |x| log(x) } # yields once; None() yields nothing +Some(1).each.to_a # => [1] ``` +`each` is the whole of the collection surface. An Option does not include `Enumerable`, because `filter` and `first` already answer Options here and Enumerable's answer plain values; `opt.each` hands you an Enumerator when you want the rest of them. + `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: diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 5a8a9c7..c89ec19 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -387,6 +387,28 @@ def to_a [value] end + # Yields the inner value once for a Some and not at all for a None, so + # an Option reads as the zero-or-one collection it is, and answers an + # Enumerator without a block. Option does not include Enumerable: its + # own filter and first answer Options, where Enumerable's answer plain + # values, and one name cannot mean both. + # + # @example + # seen = [] + # Some(1).each { |x| seen << x } # => Some(1) + # seen # => [1] + # None().each { |x| seen << x } # => None() + # seen # => [1] + # Some(1).each.to_a # => [1] + # None().each.to_a # => [] + # Some(2).each.map { |x| x * 3 } # => [6] + def each(&block) + return to_enum(:each) unless block + + block.call(value) if some? + self + end + # returns the inner value if present, else raises an error # @example # Some(1).unwrap! # => 1 From 6e436bafc724153413c92e283f544d0f9bd96a23 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 21:01:28 -0500 Subject: [PATCH 09/11] Say in the README why the mutating methods are absent take, replace, insert and get_or_insert each write through an &mut Option, and an Option here is a value rather than a slot. That reasoning was a comment at the foot of the class, where nobody deciding whether the gem covers Rust's surface would look for it; the README's Option section is where that question is asked. Removing the comment takes the access modifier that carried it, and the lint offense that came with it. --- CHANGELOG.md | 1 + README.md | 2 ++ lib/errgonomic/option.rb | 5 ----- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d34cbd..a7cedf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- [Docs] The README says why `take`, `replace`, `insert` and `get_or_insert` are absent: each writes through an `&mut Option`, and an Option here is a value rather than a slot - `Option#each` yields the inner value once for a `Some` and not at all for a `None`, and answers an Enumerator without a block, so an Option reads as the zero-or-one collection it is. `Enumerable` is deliberately not included: its `filter`, `select` and `first` answer plain values where an Option's own answer Options - [Behavior change] `Option#map_or` and `Option#map_or_else` answer the bare value their default or block gives, as Rust's do, where 0.8.x wrapped it: `Some(2).map_or(0) { |v| v * 2 }` is `4` and `None().map_or(0) { }` is `0`. They are the exit from the Option, where `map` stays inside it. `Result` has no `map_or` to correct - [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had diff --git a/README.md b/README.md index c138b97..4835e0c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ 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. + 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). ### Result diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index c89ec19..5b742af 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -755,11 +755,6 @@ def raise_blank_side_teaching(name) Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else. MSG end - - public - - # Rust's mutating combinators (insert, get_or_insert, take, replace) - # are deliberately omitted: an Option here is a value, not a slot. end # Represent a value From 780280d262a1fbc78a68f73e291ada2dccb39b09 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 21:11:17 -0500 Subject: [PATCH 10/11] Name the container in a cross-container equality refusal Some(1) == Ok(1) raised with a remedy that read "compare to Some(Ok(1))", which is not what anyone writing that line meant. An Option and a Result are different containers and comparing them is a type mistake, so it keeps raising, but the message now names both and says to unwrap whichever one was meant. != also names itself. Ruby derives it from ==, so the message used to report an operator the caller had not written. The README says which comparisons the check reaches: the wrapper as receiver, and a left operand like Integer that hands the comparison to its right-hand side, while NilClass and String answer for themselves and stay quietly false. --- CHANGELOG.md | 2 +- README.md | 9 ++++++++- lib/errgonomic/option.rb | 40 ++++++++++++++++++++++++++++++++++------ lib/errgonomic/result.rb | 38 +++++++++++++++++++++++++++++++++++--- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cedf3..879639f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - [Behavior change] `Option#map_or` and `Option#map_or_else` answer the bare value their default or block gives, as Rust's do, where 0.8.x wrapped it: `Some(2).map_or(0) { |v| v * 2 }` is `4` and `None().map_or(0) { }` is `0`. They are the exit from the Option, where `map` stays inside it. `Result` has no `map_or` to correct - [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had - `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it -- `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on +- `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`, and so does the other container: an Option compared to a Result names both and says to unwrap the one you meant. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on - `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged - [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise - `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil` diff --git a/README.md b/README.md index 4835e0c..e4f4744 100644 --- a/README.md +++ b/README.md @@ -257,10 +257,17 @@ Some(5) != 5 # => raises Some(5).eql?(5) # => raises 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 + +1 == Some(1) # => raises, through Integer's coercion fallback +nil == None() # => false, quietly +"a" == Some("a") # => false, quietly ``` -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. 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: +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. + +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: ```ruby Errgonomic.with_strict_equality do diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 5b742af..b92b8b2 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -97,12 +97,21 @@ def respond_to_missing?(name, include_private = false) # begin # Some(5) != 5 # rescue Errgonomic::TypeMismatchError => e - # e.class + # e.message.include?("!=") # end - # end # => Errgonomic::TypeMismatchError + # end # => true # Errgonomic.with_strict_equality { Some(5) == Some(5) } # => true # Errgonomic.with_strict_equality { Some(5) == None() } # => false # + # @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 + # # @example nil is another type, and absence here is the discriminant # Errgonomic.with_strict_equality do # begin @@ -140,6 +149,13 @@ def ==(other) # 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 + def eql?(other) strict_equality!(other, 'eql?') return false if self.class != other.class @@ -743,10 +759,22 @@ def strict_equality!(other, operator) return unless Errgonomic.strict_equality? return if other.is_a?(Errgonomic::Option::Any) - raise Errgonomic::TypeMismatchError, <<~MSG - #{self.class} #{operator} #{other.class} compares an Option to a value that is not one, which strict equality refuses. - #{other.nil? ? 'Absence here is the discriminant: ask none?, or nil? under the Rails integration.' : "Compare Options (opt == Some(#{other.inspect})), test the inner value (opt.some_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first."} - MSG + raise Errgonomic::TypeMismatchError, + "#{self.class} #{operator} #{other.class}, which strict equality refuses.\n" \ + "#{strict_equality_remedy(other)}" + end + + def strict_equality_remedy(other) + case other + when Errgonomic::Result::Any + 'An 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)).' + when nil + 'Absence here is the discriminant: ask none?, or nil? under the Rails integration.' + else + "Compare Options (opt == Some(#{other.inspect})), test the inner value " \ + "(opt.some_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first." + end end def raise_blank_side_teaching(name) diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 2560ba6..03589c3 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -97,6 +97,22 @@ def respond_to_missing?(name, include_private = false) # end # 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 + # + # @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 def ==(other) strict_equality!(other, '==') return false if self.class != other.class @@ -124,6 +140,13 @@ def ==(other) # end # end # => Errgonomic::TypeMismatchError # Errgonomic.with_strict_equality { Ok(5).hash == Ok(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 + def eql?(other) strict_equality!(other, 'eql?') self.class == other.class && value.eql?(other.value) @@ -441,10 +464,19 @@ def strict_equality!(other, operator) return unless Errgonomic.strict_equality? return if other.is_a?(Errgonomic::Result::Any) - raise Errgonomic::TypeMismatchError, <<~MSG - #{self.class} #{operator} #{other.class} compares a Result to a value that is not one, which strict equality refuses. - Compare Results (res == Ok(#{other.inspect})), test the inner value (res.ok_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first. + raise Errgonomic::TypeMismatchError, + "#{self.class} #{operator} #{other.class}, which strict equality refuses.\n" \ + "#{strict_equality_remedy(other)}" + end + + def strict_equality_remedy(other) + return <<~MSG.chomp if other.is_a?(Errgonomic::Option::Any) + A Result and an Option are different containers, and neither is the other. + Unwrap the one you meant (res.unwrap_or(nil) == opt.unwrap_or(nil)). MSG + + "Compare Results (res == Ok(#{other.inspect})), test the inner value " \ + "(res.ok_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first." end end From 78afbcde664a23aea78983b8f79638668bb101d4 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 8 Sep 2026 21:12:37 -0500 Subject: [PATCH 11/11] Pass TESTOPTS to the strict run, and three nits test:strict spawned a bare ruby, so --seed reached rake test and not the strict suite; TESTOPTS is split and forwarded now. An Option's Enumerator knows its size, so each.size answers 1 or 0 rather than nil. The nudge names present_or_raise! with the bang the caller wrote. The doctests that swap $stderr restore it from an ensure, so an example that fails partway cannot take the rest of the run's warnings with it. --- CHANGELOG.md | 5 +++-- Rakefile | 3 ++- lib/errgonomic/option.rb | 26 +++++++++++++++++--------- test/rails_test.rb | 16 ++++++++++++++-- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 879639f..88ef030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## [Unreleased] - [Docs] The README says why `take`, `replace`, `insert` and `get_or_insert` are absent: each writes through an `&mut Option`, and an Option here is a value rather than a slot -- `Option#each` yields the inner value once for a `Some` and not at all for a `None`, and answers an Enumerator without a block, so an Option reads as the zero-or-one collection it is. `Enumerable` is deliberately not included: its `filter`, `select` and `first` answer plain values where an Option's own answer Options +- `Option#each` yields the inner value once for a `Some` and not at all for a `None`, and answers an Enumerator that knows its size without a block, so an Option reads as the zero-or-one collection it is. `Enumerable` is deliberately not included: its `filter`, `select` and `first` answer plain values where an Option's own answer Options - [Behavior change] `Option#map_or` and `Option#map_or_else` answer the bare value their default or block gives, as Rust's do, where 0.8.x wrapped it: `Some(2).map_or(0) { |v| v * 2 }` is `4` and `None().map_or(0) { }` is `0`. They are the exit from the Option, where `map` stays inside it. `Result` has no `map_or` to correct - [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had - `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it @@ -9,7 +9,8 @@ - `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged - [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise - `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil` -- The nudge from the soft-deprecated `present_or`, `present_or_else` and `present_or_raise!` fires once per process per method rather than once per call, so a hot path no longer floods stderr +- The nudge from the soft-deprecated `present_or`, `present_or_else` and `present_or_raise!` fires once per process per method rather than once per call, so a hot path no longer floods stderr, and it names `present_or_raise!` with its bang +- [Dev, Test] `rake test:strict` passes `TESTOPTS` through to the run it spawns, so `--seed` works there as it does for `rake test` - `ActiveRecordOptional` installs its wrapped readers into a per-class module, so a model's own reader of the same name composes with the wrapper through `super` instead of one silently replacing the other - A wrapped reader lifts a value exactly one layer: an Option arriving from beneath the wrapper passes through instead of being wrapped a second time - `belongs_to` and `has_one` writers accept an Option: `Some(record)` assigns the record it wraps and `None()` clears the association, so a wrapped reader can be assigned straight onto another record diff --git a/Rakefile b/Rakefile index 6d7dd4b..af5b7cf 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'bundler/gem_tasks' +require 'shellwords' require 'rake/testtask' Rake::TestTask.new(:test) do |t| @@ -19,7 +20,7 @@ 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' + ruby '-Ilib', '-Itest', 'test/support/strict_equality.rb', *Shellwords.split(ENV.fetch('TESTOPTS', '')) end end diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index b92b8b2..4f4ddc6 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -283,7 +283,7 @@ def blank? # None().present_or_raise!("no secret") # => raise Errgonomic::NotPresentError, "no secret" # None().present_or_raise! { "no secret for #{7}" } # => raise Errgonomic::NotPresentError, "no secret for 7" def present_or_raise!(message = nil, &block) - presence_nudge('present_or_raise', 'expect!') + presence_nudge('present_or_raise!', 'expect!') raise Errgonomic::NotPresentError, block ? block.call : message if none? value @@ -306,9 +306,12 @@ def present_or_raise!(message = nil, &block) # Some(1).present_or(2) # nudges = StringIO.new # original = $stderr - # $stderr = nudges - # Some(1).present_or(2) - # $stderr = original + # begin + # $stderr = nudges + # Some(1).present_or(2) + # ensure + # $stderr = original + # end # nudges.string # => "" def present_or(default) presence_nudge('present_or', 'unwrap_or') @@ -349,10 +352,13 @@ def present_or_else(&block) # @example the Rails spelling of unwrap_or(nil), and no nudge with it # nudges = StringIO.new # original = $stderr - # $stderr = nudges - # captured = Some("").presence - # None().presence - # $stderr = original + # begin + # $stderr = nudges + # captured = Some("").presence + # None().presence + # ensure + # $stderr = original + # end # captured # => "" # nudges.string # => "" def presence @@ -418,8 +424,10 @@ def to_a # Some(1).each.to_a # => [1] # None().each.to_a # => [] # Some(2).each.map { |x| x * 3 } # => [6] + # Some(1).each.size # => 1 + # None().each.size # => 0 def each(&block) - return to_enum(:each) unless block + return to_enum(:each) { some? ? 1 : 0 } unless block block.call(value) if some? self diff --git a/test/rails_test.rb b/test/rails_test.rb index eb33cc6..4581844 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -1346,8 +1346,8 @@ def test_a_json_writer_takes_an_option assert note.reload.meta.none? end - # A numeric writer reaches its value without Option#presence, which is - # soft-deprecated and nudges on stderr on every call. + # A numeric writer reaches its value without the soft-deprecated presence + # helpers, which nudge on stderr. def test_a_numeric_writer_takes_an_option_without_a_deprecation_nudge note = nil nudges = capture_stderr do @@ -1892,6 +1892,18 @@ def test_validating_a_converted_record_with_no_validations_does_not_raise assert_predicate Note.new, :valid? end + # The nudge names the method the caller wrote, bang and all. It fires once + # per process, so this asks for it back before listening. + def test_the_present_or_raise_nudge_names_the_method_with_its_bang + Errgonomic::Option::Any::NUDGED.delete('present_or_raise!') + + nudges = capture_stderr do + assert_raises(Errgonomic::NotPresentError) { None().present_or_raise!('no bio') } + end + + assert_includes nudges, '`present_or_raise!`' + end + # ActiveSupport's Object#try asks respond_to?, which an Option answers # false for anything it does not define, so try on a wrapper is a silent # nil unless the Option sends it to the value it holds.