Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
## [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 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
- `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`
- 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
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,22 @@ 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])
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:

```ruby
Expand All @@ -113,7 +122,11 @@ 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 `#<Errgonomic::Option::Some...>` 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 `#<Errgonomic::Option::Some...>` 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.

`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.

Expand All @@ -123,9 +136,13 @@ 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.

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 }`).
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

Expand Down Expand Up @@ -158,7 +175,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

Expand Down Expand Up @@ -230,6 +247,36 @@ 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(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
```

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
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:
Expand Down Expand Up @@ -345,6 +392,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:
Expand Down
12 changes: 11 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'bundler/gem_tasks'
require 'shellwords'

require 'rake/testtask'
Rake::TestTask.new(:test) do |t|
Expand All @@ -16,7 +17,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', *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]

namespace :gems4nix do
desc 'Regenerate gem-groups.json after Gemfile/Gemfile.lock changes'
Expand Down
27 changes: 27 additions & 0 deletions lib/errgonomic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading
Loading