From df03b533116fe20156a0dc091b6390d39889e6c3 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Sat, 1 Mar 2025 10:52:31 -0600 Subject: [PATCH 01/20] I think this is most of Result --- lib/errgonomic.rb | 231 ++++++++++++++++++++++++++++++ spec/errgonomic_spec.rb | 302 ++++++++++++++++++++++++++++++++-------- 2 files changed, 477 insertions(+), 56 deletions(-) diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 9b354bf..c6a91e6 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -9,12 +9,243 @@ require_relative "errgonomic/core_ext/blank" end +class Object + # Convenience method to indicate whether we are working with a result. + # TBD whether we implement some stubs for the rest of the Result API; I want + # to think about how effectively these map to truthiness or presence. + def result? + false + end + + # Lacking static typing, we are going to want to make it easy to enforce at + # runtime that a given object is a Result. + def assert_result! + return true if result? + raise Errgonomic::ResultRequiredError + end +end + module Errgonomic class Error < StandardError; end class NotPresentError < Error; end class TypeMismatchError < Error; end + + class UnwrapError < Error; end + + class ExpectError < Error; end + + class ArgumentError < Error; end + + class ResultRequiredError < Error; end + + module Result + class Any + + # Indicate that this is some kind of result object. Contrast to + # Object#result? which is false for all other types. + def result? + true + end + + # A lightweight DSL to invoke code for a result based on whether it is an + # Ok or Err. + def match(&block) + matcher = Matcher.new(self) + matcher.instance_eval(&block) + matcher.match + end + + # Return true if the inner value is an Ok and the result of the block is + # truthy. + def ok_and?(&block) + if ok? + !!block.call(value) + else + false + end + end + + # Return true if the inner value is an Err and the result of the block is + # truthy. + def err_and?(&block) + if err? + !!block.call(value) + else + false + end + end + + # Return the inner value of an Ok, else raise an exception when Err. + def unwrap! + if ok? + @value + else + raise Errgonomic::UnwrapError, "value is an Err" + end + end + + # Return the inner value of an Ok, else raise an exception with the given + # message when Err. + def expect!(msg) + if ok? + @value + else + raise Errgonomic::ExpectError, msg + end + end + + # Return the inner value of an Err, else raise an exception when Ok. + def unwrap_err! + if err? + @value + else + raise Errgonomic::UnwrapError, "value is an Ok" + end + end + + # Given another result, return it if the inner result is Ok, else return + # the inner Err. + def and(other) + raise Errgonomic::ArgumentError, "other must be a Result" unless other.is_a?(Errgonomic::Result::Any) + return self if err? + other + end + + # Given a block, evaluate it and return its result if the inner result is + # Ok, else return the inner Err. This is lazy evaluated, and we + # pedantically check the type of the block's return value at runtime. This + # is annoying, sorry, but better than an "undefined method" error. + # Hopefully it gives your test suite a chance to detect incorrect usage. + def and_then(&block) + # raise NotImplementedError, "and_then is not implemented yet" + return self if err? + res = block.call(self) + unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + raise Errgonomic::ArgumentError, "and_then block must return a Result" + end + res + end + + # Return other if self is Ok, else return the original Err. Raises a + # pedantic runtime exception if other is not a Result. + def or(other) + raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" unless other.is_a?(Errgonomic::Result::Any) + return self if ok? + other + end + + # Return self if it is Ok, else lazy evaluate the block and return its + # result. Raises a pedantic runtime check that the block returns a Result. + # Sorry about that, hopefully it helps your tests. Better than ambiguous + # downstream "undefined method" errors, probably. + def or_else(&block) + return self if ok? + res = block.call(self) + unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + raise Errgonomic::ArgumentError, "or_else block must return a Result" + end + res + end + + # Return the inner value if self is Ok, else return the provided default. + def unwrap_or(other) + return value if ok? + other + end + + # Return the inner value if self is Ok, else lazy evaluate the block and + # return its result. + def unwrap_or_else(&block) + return value if ok? + block.call(self) + end + end + + class Ok < Any + attr_accessor :value + + def initialize(value) + @value = value + end + + def ok? + true + end + + def err? + false + end + end + + class Err < Any + class Arbitrary; end + + attr_accessor :value + + def initialize(value = Arbitrary) + @value = value + end + + def err? + true + end + + def ok? + false + end + end + + # This is my first stab at a basic DSL for matching and responding to + # different Result variants. + class Matcher + def initialize(result) + @result = result + end + + def ok(&block) + @ok_block = block + end + + def err(&block) + @err_block = block + end + + def match + case @result + when Err + @err_block.call(@result.value) + when Ok + @ok_block.call(@result.value) + else + raise Errgonomic::MatcherError, "invalid matcher" + end + end + end + end + + def self.give_me_ambiguous_downstream_errors? + @give_me_ambiguous_downstream_errors ||= false + end + + # You can opt out of the pedantic runtime checks for lazy block evaluation, + # but not quietly. + def self.with_ambiguous_downstream_errors(&block) + original_value = @give_me_ambiguous_downstream_errors + @give_me_ambiguous_downstream_errors = true + yield + ensure + @give_me_ambiguous_downstream_errors = original_value + end +end + +def Ok(value) + Errgonomic::Result::Ok.new(value) +end + +def Err(value) + Errgonomic::Result::Err.new(value) end class Object diff --git a/spec/errgonomic_spec.rb b/spec/errgonomic_spec.rb index 6654ddd..51c396e 100644 --- a/spec/errgonomic_spec.rb +++ b/spec/errgonomic_spec.rb @@ -1,93 +1,283 @@ # frozen_string_literal: true +Ok = Errgonomic::Result::Ok +Err = Errgonomic::Result::Err + RSpec.describe Errgonomic do - it "has a version number" do + it 'has a version number' do expect(Errgonomic::VERSION).not_to be nil end - describe "present_or_raise" do - it "raises an error for various blank objects" do - expect { nil.present_or_raise("foo") }.to raise_error(Errgonomic::NotPresentError) - expect { [].present_or_raise("foo") }.to raise_error(Errgonomic::NotPresentError) - expect { {}.present_or_raise("foo") }.to raise_error(Errgonomic::NotPresentError) + describe 'present_or_raise' do + it 'raises an error for various blank objects' do + expect { nil.present_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) + expect { [].present_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) + expect { {}.present_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) end - it "returns the value itself for present types" do - expect("bar".present_or_raise("foo")).to eq("bar") - expect(["baz"].present_or_raise("foo")).to eq(["baz"]) - expect({foo: "bar"}.present_or_raise("foo")).to eq({foo: "bar"}) + it 'returns the value itself for present types' do + expect('bar'.present_or_raise('foo')).to eq('bar') + expect(['baz'].present_or_raise('foo')).to eq(['baz']) + expect({ foo: 'bar' }.present_or_raise('foo')).to eq({ foo: 'bar' }) end end - describe "present_or" do - it "returns the default value for various blank objects" do - expect(nil.present_or("foo")).to eq("foo") - expect([].present_or(["foo"])).to eq(["foo"]) - expect({}.present_or({foo: "bar"})).to eq({foo: "bar"}) + describe 'present_or' do + it 'returns the default value for various blank objects' do + expect(nil.present_or('foo')).to eq('foo') + expect([].present_or(['foo'])).to eq(['foo']) + expect({}.present_or({ foo: 'bar' })).to eq({ foo: 'bar' }) end - it "rather strictly requires the value to match the starting type, except for nil" do - expect(nil.present_or("foo")).to eq("foo") - expect { [].present_or("bar") }.to raise_error(Errgonomic::TypeMismatchError) - expect { {}.present_or("bar") }.to raise_error(Errgonomic::TypeMismatchError) + it 'rather strictly requires the value to match the starting type, except for nil' do + expect(nil.present_or('foo')).to eq('foo') + expect { [].present_or('bar') }.to raise_error(Errgonomic::TypeMismatchError) + expect { {}.present_or('bar') }.to raise_error(Errgonomic::TypeMismatchError) end - it "even more strictly will fail when default value is not the same type as the original non-blank value" do - expect { ["foo"].present_or("bad") }.to raise_error(Errgonomic::TypeMismatchError) - expect { {foo: "bar"}.present_or("bad") }.to raise_error(Errgonomic::TypeMismatchError) + it 'even more strictly will fail when default value is not the same type as the original non-blank value' do + expect { ['foo'].present_or('bad') }.to raise_error(Errgonomic::TypeMismatchError) + expect { { foo: 'bar' }.present_or('bad') }.to raise_error(Errgonomic::TypeMismatchError) end - it "returns the value itself for present types" do - expect("bar".present_or("foo")).to eq("bar") - expect(["baz"].present_or(["foo"])).to eq(["baz"]) - expect({foo: "bar"}.present_or({foo: "baz"})).to eq({foo: "bar"}) + it 'returns the value itself for present types' do + expect('bar'.present_or('foo')).to eq('bar') + expect(['baz'].present_or(['foo'])).to eq(['baz']) + expect({ foo: 'bar' }.present_or({ foo: 'baz' })).to eq({ foo: 'bar' }) end end - describe "blank_or_raise" do - it "raises an error for present objects" do - expect { "bar".blank_or_raise("foo") }.to raise_error(Errgonomic::NotPresentError) - expect { ["baz"].blank_or_raise("foo") }.to raise_error(Errgonomic::NotPresentError) - expect { { foo: "bar" }.blank_or_raise("foo") }.to raise_error(Errgonomic::NotPresentError) + describe 'blank_or_raise' do + it 'raises an error for present objects' do + expect { 'bar'.blank_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) + expect { ['baz'].blank_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) + expect { { foo: 'bar' }.blank_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) end - it "returns the value itself for blank types" do - expect(nil.blank_or_raise("foo")).to eq(nil) - expect([].blank_or_raise("foo")).to eq([]) - expect({}.blank_or_raise("foo")).to eq({}) + it 'returns the value itself for blank types' do + expect(nil.blank_or_raise('foo')).to eq(nil) + expect([].blank_or_raise('foo')).to eq([]) + expect({}.blank_or_raise('foo')).to eq({}) end end - describe "blank_or" do - it "returns the receiver for blank objects" do - expect(nil.blank_or("foo")).to eq(nil) - expect([].blank_or(["foo"])).to eq([]) - expect({}.blank_or({ foo: "bar" })).to eq({}) + describe 'blank_or' do + it 'returns the receiver for blank objects' do + expect(nil.blank_or('foo')).to eq(nil) + expect([].blank_or(['foo'])).to eq([]) + expect({}.blank_or({ foo: 'bar' })).to eq({}) + end + + it 'returns the default value for present objects' do + expect('bar'.blank_or('foo')).to eq('foo') + expect(['baz'].blank_or(['foo'])).to eq(['foo']) + expect({ foo: 'bar' }.blank_or({ foo: 'baz' })).to eq({ foo: 'baz' }) end - it "returns the default value for present objects" do - expect("bar".blank_or("foo")).to eq("foo") - expect(["baz"].blank_or(["foo"])).to eq(["foo"]) - expect({ foo: "bar" }.blank_or({ foo: "baz" })).to eq({ foo: "baz" }) + it 'enforces type checks similar to present_or' do + expect { 'bar'.blank_or(['foo']) }.to raise_error(Errgonomic::TypeMismatchError) + expect { [].blank_or('foo') }.to raise_error(Errgonomic::TypeMismatchError) + end + end + + describe 'blank_or_else' do + it 'returns the receiver for blank objects' do + expect(nil.blank_or_else { 'foo' }).to eq(nil) + expect([].blank_or_else { ['foo'] }).to eq([]) + expect({}.blank_or_else { { foo: 'bar' } }).to eq({}) end - it "enforces type checks similar to present_or" do - expect { "bar".blank_or(["foo"]) }.to raise_error(Errgonomic::TypeMismatchError) - expect { [].blank_or("foo") }.to raise_error(Errgonomic::TypeMismatchError) + it 'returns the result of the block for present objects' do + expect('bar'.blank_or_else { 'foo' }).to eq('foo') + expect(['baz'].blank_or_else { ['foo'] }).to eq(['foo']) + expect({ foo: 'bar' }.blank_or_else { { foo: 'baz' } }).to eq({ foo: 'baz' }) end end - describe "blank_or_else" do - it "returns the receiver for blank objects" do - expect(nil.blank_or_else { "foo" }).to eq(nil) - expect([].blank_or_else { ["foo"] }).to eq([]) - expect({}.blank_or_else { { foo: "bar" } }).to eq({}) + describe 'Result' do + describe 'Ok' do + it 'must be constructed with an inner value' do + expect { Ok.new }.to raise_error(ArgumentError) + end + end + + describe 'Err' do + it 'can be constructed with or without an inner value' do + expect(Err.new).to be_err + expect(Err.new('foo')).to be_err + end + + it 'is err' do + result = Errgonomic::Result::Err.new + expect(result).to be_err + end + + it 'is not ok' do + result = Errgonomic::Result::Err.new + expect(result).not_to be_ok + end + + it 'raises exception on unwrap' do + result = Errgonomic::Result::Err.new('foo') + expect { result.unwrap! }.to raise_error(Errgonomic::UnwrapError) + end + + it 'raises an exception with a given message on expect' do + result = Errgonomic::Result::Err.new('foo') + expect { result.expect!('bar') }.to raise_error(Errgonomic::ExpectError) + end + end + + it 'has a basic dsl for match' do + result = Errgonomic::Result::Err.new('foo') + matched = result.match do + ok { |val| :foo } + err { |err| :bar } + end + expect(matched).to eq(:bar) + + result = Errgonomic::Result::Ok.new('foo') + matched = result.match do + ok { |val| :foo } + err { |err| :bar } + end + expect(matched).to eq(:foo) + end + + describe 'ok_and' do + it 'returns true if ok and the inner block evals to truthy, else false' do + expect(Ok.new('foo').ok_and? { true }).to be true + expect(Ok.new('foo').ok_and? { false }).to be false + expect(Err.new('foo').ok_and? { true }).to be false + expect(Err.new('foo').ok_and? { false }).to be false + end + end + + describe 'err_and?' do + it 'returns true if err and the inner block evals to truthy, else false' do + expect(Err.new('foo').err_and? { true }).to be true + expect(Err.new('foo').err_and? { false }).to be false + expect(Ok.new('foo').err_and? { true }).to be false + expect(Ok.new('foo').err_and? { false }).to be false + end + end + + describe 'and' do + it 'returns the result of the block if the original value is ok, else returns the err value' do + result = Ok.new('foo') + expect(result.and(Ok.new(:bar)).unwrap!).to eq(:bar) + + result = Err.new('foo') + expect(result.and(Ok.new(:bar))).to be_err + end + + it 'must take a block that returns a result; ew' do + result = Ok.new('foo') + expect { result.and(:bar) }.to raise_error(Errgonomic::ArgumentError) + end + end + + describe 'and_then' do + it 'returns the result from the block if the original is an ok' do + result = Ok.new('foo') + expect(result.and_then { Ok.new(:bar) }.unwrap!).to eq(:bar) + result = Err.new('foo') + expect(result.and_then { Ok.new(:bar) }).to eq(result) + end + + it 'is lazy' do + inner = Err.new('foo') + result = inner.and_then { raise 'noisy' } + expect(result).to be_err + expect(result).to eq(inner) + end + + it 'enforces the return type of the block at runtime, ew' do + inner = Ok.new('foo') + expect { inner.and_then { :bar } }.to raise_error(Errgonomic::ArgumentError) + end + + it 'can skip that runtime enforcement, which is so much worse' do + inner = Ok.new('foo') + Errgonomic.with_ambiguous_downstream_errors do + expect { inner.and_then { :bar } }.not_to raise_error + expect(inner.and_then { :baz }).to eq(:baz) + end + end + end + + describe 'or' do + it 'returns the original result when it is Ok' do + expect(Ok.new(:foo).or(Ok.new(:bar)).unwrap!).to eq(:foo) + end + + it 'returns the other result when the original is Err' do + expect(Err.new('foo').or(Ok.new(:bar)).unwrap!).to eq(:bar) + end + + it 'enforces that the other value is a result' do + expect { Err.new('foo').or(:bar) }.to raise_error(Errgonomic::ArgumentError) + end + + it 'cannot opt out of runtime enforcement' do + Errgonomic.with_ambiguous_downstream_errors do + expect { Err.new('foo').or(:bar) }.to raise_error(Errgonomic::ArgumentError) + end + end + end + + describe 'or_else' do + it 'returns the original result when it is Ok' do + expect(Ok.new(:foo).or_else { Ok.new(:bar) }.unwrap!).to eq(:foo) + end + + it 'returns the other result when the original is Err' do + expect(Err.new('foo').or_else { Ok.new(:bar) }.unwrap!).to eq(:bar) + end + + it 'enforces that the other value is a result' do + expect { Err.new('foo').or_else { :bar } }.to raise_error(Errgonomic::ArgumentError) + end + + it 'can opt out of runtime result type enforcement' do + Errgonomic.with_ambiguous_downstream_errors do + expect { Err.new('foo').or_else { :bar } }.not_to raise_error + end + end + end + + describe 'unwrap_or' do + it 'returns the contained Ok value or the provided default' do + expect(Ok.new(:foo).unwrap_or(:bar)).to eq(:foo) + expect(Err.new(:foo).unwrap_or(:bar)).to eq(:bar) + end + end + + describe 'unwrap_or_else' do + it 'returns the contained Ok value or the result of the provided block' do + expect(Ok.new(:foo).unwrap_or_else { raise 'noisy' }).to eq(:foo) + expect(Err.new(:foo).unwrap_or_else { :bar }).to eq(:bar) + end + end + + describe 'Ok()' do + it 'creates an Ok' do + expect(Ok(:foo)).to be_a(Errgonomic::Result::Ok) + end + end + + describe 'Err()' do + it 'creates an Err' do + expect(Err(:foo)).to be_a(Errgonomic::Result::Err) + end end - it "returns the result of the block for present objects" do - expect("bar".blank_or_else { "foo" }).to eq("foo") - expect(["baz"].blank_or_else { ["foo"] }).to eq(["foo"]) - expect({ foo: "bar" }.blank_or_else { { foo: "baz" } }).to eq({ foo: "baz" }) + describe 'Object#assert_result!' do + it 'raises an exception if the object is not a Result' do + expect { :foo.assert_result! }.to raise_error(Errgonomic::ResultRequiredError) + expect { Ok(:foo).assert_result! }.not_to raise_error + expect { Err(:foo).assert_result! }.not_to raise_error + end end end end From 4c4236714520ff6f27b3f9ba155909b8cd482169 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Sat, 1 Mar 2025 11:24:57 -0600 Subject: [PATCH 02/20] some of Option --- lib/errgonomic.rb | 86 +++++++++++++++++++++++++ spec/errgonomic_spec.rb | 139 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index c6a91e6..01dd36c 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -225,6 +225,84 @@ def match end end + module Option + class Any + + # return true if the contained value is Some and the block returns truthy + def some_and(&block) + return false if none? + !! block.call(self.value) + end + + # return true if the contained value is None or the block returns truthy + def none_or(&block) + return true if none? + !! block.call(self.value) + end + + # return an Array with the contained value, if any + def to_a + return [] if none? + [value] + end + + # returns the inner value if present, else raises an error + def unwrap!(message) + raise Errgonomic::UnwrapError, "cannot unwrap None" if none? + value + end + + # returns the inner value if pressent, else raises an error with the given message + def expect!(message) + raise Errgonomic::ExpectError, message if none? + value + end + + # returns the inner value if present, else returns the default value + def unwrap_or(default) + return default if none? + value + end + + # returns the inner value if present, else returns the result of the provided block + def unwrap_or_else(&block) + return block.call if none? + value + end + + # TODO: figure out an appropriate name for this one. rust calls it + # "inspect" but in Ruby that's closer to to_s of a debug string. in Ruby + # the semantic here is tap, but that takes the original object, and this + # one is more specifically an inner value if it exists, which I am + # hesitant to replace. + # + # Calls a function with the inner value, if Some, but returns the original option. + def tap_some(&block) + block.call(value) if some? + self + end + + def map_or(default, &block) + return default if none? + block.call(value) + end + + end + + class Some < Any + attr_accessor :value + def initialize(value) + @value = value + end + def some?; true; end + def none?; false; end + end + class None < Any + def some?; false; end + def none?; true; end + end + end + def self.give_me_ambiguous_downstream_errors? @give_me_ambiguous_downstream_errors ||= false end @@ -248,6 +326,14 @@ def Err(value) Errgonomic::Result::Err.new(value) end +def Some(value) + Errgonomic::Option::Some.new(value) +end + +def None() + Errgonomic::Option::None.new() +end + class Object # Returns the receiver if it is present, otherwise raises a NotPresentError. # This method is useful to enforce strong expectations, where it is preferable diff --git a/spec/errgonomic_spec.rb b/spec/errgonomic_spec.rb index 51c396e..e82d6ef 100644 --- a/spec/errgonomic_spec.rb +++ b/spec/errgonomic_spec.rb @@ -3,6 +3,9 @@ Ok = Errgonomic::Result::Ok Err = Errgonomic::Result::Err +Some = Errgonomic::Option::Some +None = Errgonomic::Option::None + RSpec.describe Errgonomic do it 'has a version number' do expect(Errgonomic::VERSION).not_to be nil @@ -279,5 +282,141 @@ expect { Err(:foo).assert_result! }.not_to raise_error end end + end + + describe "Option" do + describe "some" do + it "can be created with Some()" do + expect(Some(:foo)).to be_a(Errgonomic::Option::Some) + end + it "is some" do + expect(Some(:foo)).to be_some + end + it "is not none" do + expect(Some(:foo)).not_to be_none + end + end + + describe "None" do + it "can be created with None()" do + expect(None()).to be_a(Errgonomic::Option::None) + end + it "is none" do + expect(None()).to be_none + end + it "is not some" do + expect(None()).not_to be_some + end + end + + describe "some_and" do + it "returns true if the option is Some and the block is truthy" do + expect(Some(:foo).some_and { true }).to be true + expect(Some(:foo).some_and { false }).to be false + end + it "returns false if the option is None" do + expect(None().some_and { true }).to be false + expect(None().some_and { false }).to be false + end + end + + describe "none_or" do + it "returns true if the option is None" do + expect(None().none_or { true }).to be true + expect(None().none_or { false }).to be true + end + it "returns true if the block is truthy" do + expect(Some(:foo).none_or { true }).to be true + expect(Some(:foo).none_or { false }).to be false + end + end + + describe "to_a" do + it "returns an array of the contained value, if any" do + expect(Some(:foo).to_a).to eq([:foo]) + expect(None().to_a).to eq([]) + end + end + + describe "expect!" do + it "returns the inner value or else raises an error with the given message" do + expect(Some(:foo).expect!("msg")).to eq(:foo) + expect { None().expect!("msg") }.to raise_error(Errgonomic::ExpectError, "msg") + end + end + + describe "unwrap!" do + it "returns the inner value or else raises an error" do + expect(Some(:foo).unwrap!("foo")).to eq(:foo) + expect { None().unwrap!("foo") }.to raise_error(Errgonomic::UnwrapError) + end + end + + describe "unwrap_or" do + it "returns the inner value if present, or the provided value" do + expect(Some(:foo).unwrap_or(:bar)).to eq(:foo) + expect(None().unwrap_or(:bar)).to eq(:bar) + end + end + + describe "unwrap_or_else" do + it "returns the inner value if present, or the result of the provided block" do + expect(Some(:foo).unwrap_or_else { :bar }).to eq(:foo) + expect(None().unwrap_or_else { :bar }).to eq(:bar) + end + end + + describe "tap_some" do + it "calls the block with the inner value, if some, returning the original Option" do + option = Some(:foo) + tapped = false + expect(option.tap_some { |v| tapped = true }).to eq(option) + expect(tapped).to be true + option = None() + tapped = false + expect(option.tap_some { |v| tapped = true }).to eq(option) + expect(tapped).to be false + end + end + + describe "map" + + describe "map_or" do + it "returns the provided result (if None) or applies a function to the contained value (if Some)" do + option = Some(0) + val = option.map_or(100) { |v| v + 1 } + expect(val).to eq(1) + end + end + + describe "map_or_else" + + describe "ok_or" + describe "ok_or_else" + + describe "and" + describe "and_then" + + describe "filter" + + describe "or" + describe "or_else" + describe "xor" + + describe "insert" + describe "get_or_insert" + describe "get_or_insert_with" + + describe "take" + describe "take_if" + describe "replace" + + describe "zip" + describe "zip_with" + + + + + end end From b229eee247a62f526e9238e2ecb8a127243543a0 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Sat, 1 Mar 2025 12:45:12 -0600 Subject: [PATCH 03/20] yard-doctest and more of option --- .yardopts | 1 + Gemfile | 3 +- Gemfile.lock | 53 ++++++++- Rakefile | 6 + doctest_helper.rb | 1 + errgonomic.gemspec | 10 +- flake.nix | 26 +++-- gemset.nix | 208 +++++++++++++++++++++++++++++++++++ lib/errgonomic.rb | 267 ++++++++++++++++++++++++++++++++++++++++++--- 9 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 .yardopts create mode 100644 doctest_helper.rb diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..9c072fa --- /dev/null +++ b/.yardopts @@ -0,0 +1 @@ +--plugin yard-doctest diff --git a/Gemfile b/Gemfile index 432b36b..1af52e7 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,6 @@ source "https://rubygems.org" gemspec gem "rake", "~> 13.0" - gem "rspec", "~> 3.0" - gem "standard", "~> 1.3" +gem "solargraph" diff --git a/Gemfile.lock b/Gemfile.lock index a1c2d47..8796c5c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,11 +8,28 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) + backport (1.2.0) + benchmark (0.4.0) concurrent-ruby (1.3.5) diff-lcs (1.6.0) + jaro_winkler (1.6.0) json (2.10.1) + kramdown (2.5.1) + rexml (>= 3.3.9) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) language_server-protocol (3.17.0.4) lint_roller (1.1.0) + logger (1.6.6) + mini_portile2 (2.8.8) + minitest (5.25.4) + nokogiri (1.18.3) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.18.3-arm64-darwin) + racc (~> 1.4) + observer (0.1.2) + ostruct (0.6.1) parallel (1.26.3) parser (3.3.7.1) ast (~> 2.4.1) @@ -20,7 +37,12 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.2.1) + rbs (3.8.1) + logger regexp_parser (2.10.0) + reverse_markdown (3.0.0) + nokogiri + rexml (3.4.1) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -50,6 +72,25 @@ GEM rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) + solargraph (0.52.0) + backport (~> 1.2) + benchmark + bundler (~> 2.0) + diff-lcs (~> 1.4) + jaro_winkler (~> 1.6) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.1) + logger (~> 1.6) + observer (~> 0.1) + ostruct (~> 0.6) + parser (~> 3.0) + rbs (~> 3.0) + reverse_markdown (>= 2.0, < 4) + rubocop (~> 1.38) + thor (~> 1.0) + tilt (~> 2.0) + yard (~> 0.9, >= 0.9.24) + yard-solargraph (~> 0.1) standard (1.45.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -62,19 +103,29 @@ GEM standard-performance (1.6.0) lint_roller (~> 1.1) rubocop-performance (~> 1.23.0) + thor (1.3.2) + tilt (2.6.0) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) + yard (0.9.37) + yard-doctest (0.1.17) + minitest + yard + yard-solargraph (0.1.0) + yard (~> 0.9) PLATFORMS - arm64-darwin-24 ruby DEPENDENCIES errgonomic! rake (~> 13.0) rspec (~> 3.0) + solargraph standard (~> 1.3) + yard (~> 0.9) + yard-doctest (~> 0.1) BUNDLED WITH 2.5.22 diff --git a/Rakefile b/Rakefile index df40677..a4cd7c6 100644 --- a/Rakefile +++ b/Rakefile @@ -6,5 +6,11 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) require "standard/rake" +require "yard/doctest/rake" task default: %i[spec standard] + +YARD::Doctest::RakeTask.new do |task| + task.doctest_opts = %w[-v] + task.pattern = 'lib/**/*.rb' +end diff --git a/doctest_helper.rb b/doctest_helper.rb new file mode 100644 index 0000000..262b7d0 --- /dev/null +++ b/doctest_helper.rb @@ -0,0 +1 @@ +require_relative "lib/errgonomic" diff --git a/errgonomic.gemspec b/errgonomic.gemspec index 2b6b5fd..f53492f 100644 --- a/errgonomic.gemspec +++ b/errgonomic.gemspec @@ -1,6 +1,12 @@ # frozen_string_literal: true -require_relative "lib/errgonomic/version" +# when we build in the nix store, version.rb is hashed and adjacent to the gemspec +if __FILE__.include?("/nix/store") + version_file = Dir.glob("./*-version.rb").first + require_relative version_file +else + require_relative "lib/errgonomic/version" +end Gem::Specification.new do |spec| spec.name = "errgonomic" @@ -34,6 +40,8 @@ Gem::Specification.new do |spec| # spec.add_dependency "example-gem", "~> 1.0" spec.add_dependency "concurrent-ruby", "~> 1.0" + spec.add_development_dependency "yard", "~> 0.9" + spec.add_development_dependency "yard-doctest", "~> 0.1" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/flake.nix b/flake.nix index e01754b..7b270fa 100644 --- a/flake.nix +++ b/flake.nix @@ -15,13 +15,6 @@ "aarch64-darwin" ]; overlays = [ - (final: prev: { - gems = final.bundlerEnv { - name = "errgonomic"; - gemdir = ./.; - # src = final.lib.cleanSource ../.; - }; - }) ]; forAllSystems = f: @@ -36,12 +29,25 @@ { devShells = forAllSystems ( { pkgs, ... }: + let + inherit (pkgs) ruby bundix; + in { default = pkgs.mkShell { buildInputs = [ - pkgs.ruby - pkgs.bundix - pkgs.gems + ruby + bundix + (pkgs.bundlerEnv { + name = "errgonomic"; + gemdir = ./.; + extraConfigPaths = [ + ./errgonomic.gemspec + ./lib/errgonomic/version.rb + ]; + postInstall = '' + find . >&2 + ''; + }) ]; }; } diff --git a/gemset.nix b/gemset.nix index 146b2c0..5d34e4f 100644 --- a/gemset.nix +++ b/gemset.nix @@ -9,6 +9,26 @@ }; version = "2.4.2"; }; + backport = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0xbzzjrgah0f8ifgd449kak2vyf30micpz6x2g82aipfv7ypsb4i"; + type = "gem"; + }; + version = "1.2.0"; + }; + benchmark = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0jl71qcgamm96dzyqk695j24qszhcc7liw74qc83fpjljp2gh4hg"; + type = "gem"; + }; + version = "0.4.0"; + }; concurrent-ruby = { groups = ["default"]; platforms = []; @@ -39,6 +59,16 @@ }; version = "0.1.0"; }; + jaro_winkler = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "09645h5an19zc1i7wlmixszj8xxqb2zc8qlf8dmx39bxpas1l24b"; + type = "gem"; + }; + version = "1.6.0"; + }; json = { groups = ["default"]; platforms = []; @@ -49,6 +79,28 @@ }; version = "2.10.1"; }; + kramdown = { + dependencies = ["rexml"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "131nwypz8b4pq1hxs6gsz3k00i9b75y3cgpkq57vxknkv6mvdfw7"; + type = "gem"; + }; + version = "2.5.1"; + }; + kramdown-parser-gfm = { + dependencies = ["kramdown"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0a8pb3v951f4x7h968rqfsa19c8arz21zw1vaj42jza22rap8fgv"; + type = "gem"; + }; + version = "1.1.0"; + }; language_server-protocol = { groups = ["default"]; platforms = []; @@ -69,6 +121,67 @@ }; version = "1.1.0"; }; + logger = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "05s008w9vy7is3njblmavrbdzyrwwc1fsziffdr58w9pwqj8sqfx"; + type = "gem"; + }; + version = "1.6.6"; + }; + mini_portile2 = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0x8asxl83msn815lwmb2d7q5p29p7drhjv5va0byhk60v9n16iwf"; + type = "gem"; + }; + version = "2.8.8"; + }; + minitest = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0izrg03wn2yj3gd76ck7ifbm9h2kgy8kpg4fk06ckpy4bbicmwlw"; + type = "gem"; + }; + version = "5.25.4"; + }; + nokogiri = { + dependencies = ["mini_portile2" "racc"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0npx535cs8qc33n0lpbbwl0p9fi3a5bczn6ayqhxvknh9yqw77vb"; + type = "gem"; + }; + version = "1.18.3"; + }; + observer = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1b2h1642jy1xrgyakyzz6bkq43gwp8yvxrs8sww12rms65qi18yq"; + type = "gem"; + }; + version = "0.1.2"; + }; + ostruct = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "05xqijcf80sza5pnlp1c8whdaay8x5dc13214ngh790zrizgp8q9"; + type = "gem"; + }; + version = "0.6.1"; + }; parallel = { groups = ["default"]; platforms = []; @@ -120,6 +233,17 @@ }; version = "13.2.1"; }; + rbs = { + dependencies = ["logger"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "07cwjkx7b3ssy8ccqq1s34sc5snwvgxan2ikmp9y2rz2a9wy6v1b"; + type = "gem"; + }; + version = "3.8.1"; + }; regexp_parser = { groups = ["default"]; platforms = []; @@ -130,6 +254,27 @@ }; version = "2.10.0"; }; + reverse_markdown = { + dependencies = ["nokogiri"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "195c7yra7amggqj7rir0yr09r4v29c2hgkbkb21mj0jsfs3868mb"; + type = "gem"; + }; + version = "3.0.0"; + }; + rexml = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1jmbf6lf7pcyacpb939xjjpn1f84c3nw83dy3p1lwjx0l2ljfif7"; + type = "gem"; + }; + version = "3.4.1"; + }; rspec = { dependencies = ["rspec-core" "rspec-expectations" "rspec-mocks"]; groups = ["default"]; @@ -227,6 +372,17 @@ }; version = "1.13.0"; }; + solargraph = { + dependencies = ["backport" "benchmark" "diff-lcs" "jaro_winkler" "kramdown" "kramdown-parser-gfm" "logger" "observer" "ostruct" "parser" "rbs" "reverse_markdown" "rubocop" "thor" "tilt" "yard" "yard-solargraph"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0fqa486hfn6kdbqp3ppy3jvl9xyj8jz41a2dzgkhc6ny2pj31w92"; + type = "gem"; + }; + version = "0.52.0"; + }; standard = { dependencies = ["language_server-protocol" "lint_roller" "rubocop" "standard-custom" "standard-performance"]; groups = ["default"]; @@ -260,6 +416,26 @@ }; version = "1.6.0"; }; + thor = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1nmymd86a0vb39pzj2cwv57avdrl6pl3lf5bsz58q594kqxjkw7f"; + type = "gem"; + }; + version = "1.3.2"; + }; + tilt = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0szpapi229v3scrvw1pgy0vpjm7z3qlf58m1198kxn70cs278g96"; + type = "gem"; + }; + version = "2.6.0"; + }; unicode-display_width = { dependencies = ["unicode-emoji"]; groups = ["default"]; @@ -281,4 +457,36 @@ }; version = "4.0.4"; }; + yard = { + groups = ["development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "14k9lb9a60r9z2zcqg08by9iljrrgjxdkbd91gw17rkqkqwi1sd6"; + type = "gem"; + }; + version = "0.9.37"; + }; + yard-doctest = { + dependencies = ["minitest" "yard"]; + groups = ["development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0zw4fa5ri58w76yawh0sc9xj69z26qm59lvjxr1gqn4zv5smmcvw"; + type = "gem"; + }; + version = "0.1.17"; + }; + yard-solargraph = { + dependencies = ["yard"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "03lklm47k6k294ww97x6zpvlqlyjm5q8jidrixhil622r4cld6m1"; + type = "gem"; + }; + version = "0.1.0"; + }; } diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 01dd36c..868f7a3 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -13,12 +13,20 @@ class Object # Convenience method to indicate whether we are working with a result. # TBD whether we implement some stubs for the rest of the Result API; I want # to think about how effectively these map to truthiness or presence. + # + # @example + # "foo".result? # => false + # Ok("foo").result? # => true def result? false end # Lacking static typing, we are going to want to make it easy to enforce at # runtime that a given object is a Result. + # + # @example + # "foo".assert_result! # => raise Errgonomic::ResultRequiredError + # Ok("foo").assert_result! # => true def assert_result! return true if result? raise Errgonomic::ResultRequiredError @@ -43,14 +51,39 @@ class ResultRequiredError < Error; end module Result class Any + # Equality comparison for Result objects is based on value not reference. + # + # @example + # Ok("foo") == Ok("foo") # => true + # Ok("foo") == Err("foo") # => false + # Ok("foo").object_id != Ok("foo").object_id # => true + def ==(other) + self.class == other.class && self.value == other.value + end + # Indicate that this is some kind of result object. Contrast to # Object#result? which is false for all other types. + # @example + # Ok("foo").result? # => true + # Err("foo").result? # => true + # "foo".result? # => false def result? true end # A lightweight DSL to invoke code for a result based on whether it is an # Ok or Err. + # + # @example + # Ok("foo").match do + # ok { :foo } + # err { :bar } + # end # => :foo + # + # Err("foo").match do + # ok { :foo } + # err { :bar } + # end # => :bar def match(&block) matcher = Matcher.new(self) matcher.instance_eval(&block) @@ -59,6 +92,12 @@ def match(&block) # Return true if the inner value is an Ok and the result of the block is # truthy. + # + # @example + # Ok("foo").ok_and? { |_| true } # => true + # Ok("foo").ok_and? { |_| false } # => false + # Err("foo").ok_and? { |_| true } # => false + # Err("foo").ok_and? { |_| false } # => false def ok_and?(&block) if ok? !!block.call(value) @@ -69,6 +108,12 @@ def ok_and?(&block) # Return true if the inner value is an Err and the result of the block is # truthy. + # + # @example + # Ok("foo").err_and? { |_| true } # => false + # Ok("foo").err_and? { |_| false } # => false + # Err("foo").err_and? { |_| true } # => true + # Err("foo").err_and? { |_| false } # => false def err_and?(&block) if err? !!block.call(value) @@ -78,6 +123,10 @@ def err_and?(&block) end # Return the inner value of an Ok, else raise an exception when Err. + # + # @example + # Ok("foo").unwrap! # => "foo" + # Err("foo").unwrap! # => raise Errgonomic::UnwrapError, "value is an Err" def unwrap! if ok? @value @@ -88,6 +137,10 @@ def unwrap! # Return the inner value of an Ok, else raise an exception with the given # message when Err. + # + # @example + # Ok("foo").expect!("foo") # => "foo" + # Err("foo").expect!("foo") # => raise Errgonomic::ExpectError, "foo" def expect!(msg) if ok? @value @@ -97,6 +150,10 @@ def expect!(msg) end # Return the inner value of an Err, else raise an exception when Ok. + # + # @example + # Ok("foo").unwrap_err! # => raise Errgonomic::UnwrapError, "value is an Ok" + # Err("foo").unwrap_err! # => "foo" def unwrap_err! if err? @value @@ -106,7 +163,14 @@ def unwrap_err! end # Given another result, return it if the inner result is Ok, else return - # the inner Err. + # the inner Err. Raise an exception if the other value is not a Result. + # + # @example + # Ok("foo").and(Ok("bar")) # => Ok("bar") + # Ok("foo").and(Err("bar")) # => Err("bar") + # Err("foo").and(Ok("bar")) # => Err("foo") + # Err("foo").and(Err("bar")) # => Err("foo") + # Ok("foo").and("bar") # => raise Errgonomic::ArgumentError, "other must be a Result" def and(other) raise Errgonomic::ArgumentError, "other must be a Result" unless other.is_a?(Errgonomic::Result::Any) return self if err? @@ -128,18 +192,29 @@ def and_then(&block) res end - # Return other if self is Ok, else return the original Err. Raises a + # Return other if self is Err, else return the original Option. Raises a # pedantic runtime exception if other is not a Result. + # + # @example + # Err("foo").or(Ok("bar")) # => Ok("bar") + # Err("foo").or(Err("baz")) # => Err("baz") + # Err("foo").or("bar") # => raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" def or(other) raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" unless other.is_a?(Errgonomic::Result::Any) - return self if ok? - other + return other if err? + self end # Return self if it is Ok, else lazy evaluate the block and return its # result. Raises a pedantic runtime check that the block returns a Result. # Sorry about that, hopefully it helps your tests. Better than ambiguous # downstream "undefined method" errors, probably. + # + # @example + # Ok("foo").or_else { Ok("bar") } # => Ok("foo") + # Err("foo").or_else { Ok("bar") } # => Ok("bar") + # Err("foo").or_else { Err("baz") } # => Err("baz") + # Err("foo").or_else { "bar" } # => raise Errgonomic::ArgumentError, "or_else block must return a Result" def or_else(&block) return self if ok? res = block.call(self) @@ -150,6 +225,10 @@ def or_else(&block) end # Return the inner value if self is Ok, else return the provided default. + # + # @example + # Ok("foo").unwrap_or("bar") # => "foo" + # Err("foo").unwrap_or("bar") # => "bar" def unwrap_or(other) return value if ok? other @@ -157,6 +236,10 @@ def unwrap_or(other) # Return the inner value if self is Ok, else lazy evaluate the block and # return its result. + # + # @example + # Ok("foo").unwrap_or_else { "bar" } # => "foo" + # Err("foo").unwrap_or_else { "bar" } # => "bar" def unwrap_or_else(&block) return value if ok? block.call(self) @@ -170,10 +253,18 @@ def initialize(value) @value = value end + # Ok is always ok + # + # @example + # Ok("foo").ok? # => true def ok? true end + # Ok is never err + # + # @example + # Ok("foo").err? # => false def err? false end @@ -184,14 +275,27 @@ class Arbitrary; end attr_accessor :value + # Err may be constructed without a value, if you want. + # + # @example + # Err("foo").value # => "foo" + # Err().value # => Arbitrary def initialize(value = Arbitrary) @value = value end + # Err is always err + # + # @example + # Err("foo").err? # => true def err? true end + # Err is never ok + # + # @example + # Err("foo").ok? # => false def ok? false end @@ -199,6 +303,17 @@ def ok? # This is my first stab at a basic DSL for matching and responding to # different Result variants. + # + # @example + # Err("foo").match do + # ok { :ok } + # err { :err } + # end # => :err + # + # Ok("foo").match do + # ok { :ok } + # err { :err } + # end # => :ok class Matcher def initialize(result) @result = result @@ -228,65 +343,185 @@ def match module Option class Any + def ==(other) + return true if self.none? && other.none? + return true if self.some? && other.some? && self.value == other.value + false + end + # return true if the contained value is Some and the block returns truthy + # + # @example + # Some(1).some_and { |x| x > 0 } # => true + # Some(0).some_and { |x| x > 0 } # => false + # None().some_and { |x| x > 0 } # => false def some_and(&block) return false if none? !! block.call(self.value) end # return true if the contained value is None or the block returns truthy + # + # @example + # None().none_or { false } # => true + # Some(1).none_or { |x| x > 0 } # => true + # Some(1).none_or { |x| x < 0 } # => false def none_or(&block) return true if none? !! block.call(self.value) end # return an Array with the contained value, if any + # @example + # Some(1).to_a # => [1] + # None().to_a # => [] def to_a return [] if none? [value] end # returns the inner value if present, else raises an error - def unwrap!(message) + # @example + # Some(1).unwrap! # => 1 + # None().unwrap! # => raise Errgonomic::UnwrapError, "cannot unwrap None" + def unwrap! raise Errgonomic::UnwrapError, "cannot unwrap None" if none? value end - # returns the inner value if pressent, else raises an error with the given message - def expect!(message) - raise Errgonomic::ExpectError, message if none? + # returns the inner value if pressent, else raises an error with the given + # message + # @example + # Some(1).expect!("msg") # => 1 + # None().expect!("msg") # => raise Errgonomic::ExpectError, "msg" + def expect!(msg) + raise Errgonomic::ExpectError, msg if none? value end # returns the inner value if present, else returns the default value + # @example + # Some(1).unwrap_or(2) # => 1 + # None().unwrap_or(2) # => 2 def unwrap_or(default) return default if none? value end - # returns the inner value if present, else returns the result of the provided block + # returns the inner value if present, else returns the result of the + # provided block + # @example + # Some(1).unwrap_or_else { 2 } # => 1 + # None().unwrap_or_else { 2 } # => 2 def unwrap_or_else(&block) return block.call if none? value end - # TODO: figure out an appropriate name for this one. rust calls it - # "inspect" but in Ruby that's closer to to_s of a debug string. in Ruby - # the semantic here is tap, but that takes the original object, and this - # one is more specifically an inner value if it exists, which I am - # hesitant to replace. + # Calls a function with the inner value, if Some, but returns the original + # option. In Rust, this is "inspect" but that clashes with Ruby + # conventions. We call this "tap_some" to avoid further clashing with + # "tap." # - # Calls a function with the inner value, if Some, but returns the original option. + # @example + # tapped = false + # Some(1).tap_some { |x| tapped = x } # => Some(1) + # tapped # => 1 + # tapped = false + # None().tap_some { tapped = true } # => None() + # tapped # => false def tap_some(&block) block.call(value) if some? self 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. + # @example + # Some(1).map { |x| Some(x + 1) } # => Some(2) + # Some(1).map { |x| None() } # => None() + # None().map { Some(1) } # => None() + # Some(1).map { :foo } # => raise Errgonomic::ArgumentError, "block must return an Option" + def map(&block) + return self if none? + res = block.call(value) + unless res.is_a?(Errgonomic::Option::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + raise ArgumentError, "block must return an Option" + end + res + 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+. + # + # @example + # None().map_or(1) { 100 } # => 1 + # Some(1).map_or(1) { |x| x + 1 } # => 2 + # Some("foo").map_or(0) { |str| str.length } # => 3 def map_or(default, &block) return default if none? block.call(value) end + # Computes a default from the given Proc if None, or applies the block to + # the contained value (if Some). + # + # @example + # None().map_or_else(-> { :foo }) { :bar } # => :foo + # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3 + def map_or_else(proc, &block) + return proc.call if none? + block.call(value) + end + + # convert the option into a result where Some is Ok and None is Err + # @example + # None().ok # => Err() + # Some(1).ok # => Ok(1) + def ok + return Errgonomic::Result::Ok.new(value) if some? + Errgonomic::Result::Err.new + end + + # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err) + # + # @example + # None().ok_or("wow") # => Err("wow") + # Some(1).ok_or("such err") # => Ok(1) + def ok_or(err) + return Errgonomic::Result::Ok.new(value) if some? + Errgonomic::Result::Err.new(err) + end + + # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err). + # TODO: block or proc? + # + # @example + # None().ok_or_else { "wow" } # => Err("wow") + # Some(1).ok_or_else { "such err" } # => Ok(1) + def ok_or_else(&block) + return Errgonomic::Result::Ok.new(value) if some? + Errgonomic::Result::Err.new(block.call) + end + + # TODO: + # and + # and_then + # filter + # or + # or_else + # xor + # insert + # get_or_insert + # get_or_insert_with + # take + # take_if + # replace + # zip + # zip_with + end class Some < Any @@ -322,7 +557,7 @@ def Ok(value) Errgonomic::Result::Ok.new(value) end -def Err(value) +def Err(value = Errgonomic::Result::Err::Arbitrary) Errgonomic::Result::Err.new(value) end From a6631fb3d2fb7f46c6e2bcf1b4dfa055a79b2252 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 4 Mar 2025 17:02:28 -0600 Subject: [PATCH 04/20] lint --- Rakefile | 2 +- lib/errgonomic.rb | 56 ++++++++++++++++++++++++++++------------- spec/errgonomic_spec.rb | 5 ---- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/Rakefile b/Rakefile index a4cd7c6..bf8402d 100644 --- a/Rakefile +++ b/Rakefile @@ -12,5 +12,5 @@ task default: %i[spec standard] YARD::Doctest::RakeTask.new do |task| task.doctest_opts = %w[-v] - task.pattern = 'lib/**/*.rb' + task.pattern = "lib/**/*.rb" end diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 868f7a3..d65be91 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -50,7 +50,6 @@ class ResultRequiredError < Error; end module Result class Any - # Equality comparison for Result objects is based on value not reference. # # @example @@ -58,7 +57,7 @@ class Any # Ok("foo") == Err("foo") # => false # Ok("foo").object_id != Ok("foo").object_id # => true def ==(other) - self.class == other.class && self.value == other.value + self.class == other.class && value == other.value end # Indicate that this is some kind of result object. Contrast to @@ -342,10 +341,9 @@ def match module Option class Any - def ==(other) - return true if self.none? && other.none? - return true if self.some? && other.some? && self.value == other.value + return true if none? && other.none? + return true if some? && other.some? && value == other.value false end @@ -357,7 +355,8 @@ def ==(other) # None().some_and { |x| x > 0 } # => false def some_and(&block) return false if none? - !! block.call(self.value) + + !!block.call(value) end # return true if the contained value is None or the block returns truthy @@ -368,7 +367,8 @@ def some_and(&block) # Some(1).none_or { |x| x < 0 } # => false def none_or(&block) return true if none? - !! block.call(self.value) + + !!block.call(value) end # return an Array with the contained value, if any @@ -385,7 +385,8 @@ def to_a # Some(1).unwrap! # => 1 # None().unwrap! # => raise Errgonomic::UnwrapError, "cannot unwrap None" def unwrap! - raise Errgonomic::UnwrapError, "cannot unwrap None" if none? + raise Errgonomic::UnwrapError, 'cannot unwrap None' if none? + value end @@ -396,6 +397,7 @@ def unwrap! # None().expect!("msg") # => raise Errgonomic::ExpectError, "msg" def expect!(msg) raise Errgonomic::ExpectError, msg if none? + value end @@ -405,6 +407,7 @@ def expect!(msg) # None().unwrap_or(2) # => 2 def unwrap_or(default) return default if none? + value end @@ -415,6 +418,7 @@ def unwrap_or(default) # None().unwrap_or_else { 2 } # => 2 def unwrap_or_else(&block) return block.call if none? + value end @@ -445,10 +449,12 @@ def tap_some(&block) # Some(1).map { :foo } # => raise Errgonomic::ArgumentError, "block must return an Option" def map(&block) return self if none? + res = block.call(value) unless res.is_a?(Errgonomic::Option::Any) || Errgonomic.give_me_ambiguous_downstream_errors? - raise ArgumentError, "block must return an Option" + raise ArgumentError, 'block must return an Option' end + res end @@ -462,6 +468,7 @@ def map(&block) # Some("foo").map_or(0) { |str| str.length } # => 3 def map_or(default, &block) return default if none? + block.call(value) end @@ -473,6 +480,7 @@ def map_or(default, &block) # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3 def map_or_else(proc, &block) return proc.call if none? + block.call(value) end @@ -482,6 +490,7 @@ def map_or_else(proc, &block) # Some(1).ok # => Ok(1) def ok return Errgonomic::Result::Ok.new(value) if some? + Errgonomic::Result::Err.new end @@ -492,6 +501,7 @@ def ok # Some(1).ok_or("such err") # => Ok(1) def ok_or(err) return Errgonomic::Result::Ok.new(value) if some? + Errgonomic::Result::Err.new(err) end @@ -500,9 +510,10 @@ def ok_or(err) # # @example # None().ok_or_else { "wow" } # => Err("wow") - # Some(1).ok_or_else { "such err" } # => Ok(1) + # Some("foo").ok_or_else { "such err" } # => Ok("foo") def ok_or_else(&block) return Errgonomic::Result::Ok.new(value) if some? + Errgonomic::Result::Err.new(block.call) end @@ -521,7 +532,6 @@ def ok_or_else(&block) # replace # zip # zip_with - end class Some < Any @@ -529,12 +539,24 @@ class Some < Any def initialize(value) @value = value end - def some?; true; end - def none?; false; end + + def some? + true + end + + def none? + false + end end + class None < Any - def some?; false; end - def none?; true; end + def some? + false + end + + def none? + true + end end end @@ -565,8 +587,8 @@ def Some(value) Errgonomic::Option::Some.new(value) end -def None() - Errgonomic::Option::None.new() +def None + Errgonomic::Option::None.new end class Object diff --git a/spec/errgonomic_spec.rb b/spec/errgonomic_spec.rb index e82d6ef..643b192 100644 --- a/spec/errgonomic_spec.rb +++ b/spec/errgonomic_spec.rb @@ -413,10 +413,5 @@ describe "zip" describe "zip_with" - - - - - end end From b3fa9e74f11a2422c4a74ebe038bba70ec0d5509 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 4 Mar 2025 17:29:58 -0600 Subject: [PATCH 05/20] fine, fine, let's use rubocop --- Gemfile | 12 ++++--- Gemfile.lock | 20 +---------- Rakefile | 10 +++--- gemset.nix | 54 ----------------------------- lib/errgonomic.rb | 86 ++++++++++++++++++++++++++++++----------------- 5 files changed, 69 insertions(+), 113 deletions(-) diff --git a/Gemfile b/Gemfile index 1af52e7..524e664 100644 --- a/Gemfile +++ b/Gemfile @@ -1,11 +1,13 @@ # frozen_string_literal: true -source "https://rubygems.org" +source 'https://rubygems.org' # Specify your gem's dependencies in errgonomic.gemspec gemspec -gem "rake", "~> 13.0" -gem "rspec", "~> 3.0" -gem "standard", "~> 1.3" -gem "solargraph" +gem 'rake', '~> 13.0', group: :development +gem 'rspec', '~> 3.0', group: :development +gem 'rubocop', group: :development +gem 'solargraph', group: :development + +# gem "standard", "~> 1.3", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 8796c5c..33f1286 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -19,15 +19,12 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.4) - lint_roller (1.1.0) logger (1.6.6) mini_portile2 (2.8.8) minitest (5.25.4) nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.3-arm64-darwin) - racc (~> 1.4) observer (0.1.2) ostruct (0.6.1) parallel (1.26.3) @@ -68,9 +65,6 @@ GEM unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.38.1) parser (>= 3.3.1.0) - rubocop-performance (1.23.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) solargraph (0.52.0) backport (~> 1.2) @@ -91,18 +85,6 @@ GEM tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) yard-solargraph (~> 0.1) - standard (1.45.0) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.71.0) - standard-custom (~> 1.0.0) - standard-performance (~> 1.6) - standard-custom (1.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.50) - standard-performance (1.6.0) - lint_roller (~> 1.1) - rubocop-performance (~> 1.23.0) thor (1.3.2) tilt (2.6.0) unicode-display_width (3.1.4) @@ -122,8 +104,8 @@ DEPENDENCIES errgonomic! rake (~> 13.0) rspec (~> 3.0) + rubocop solargraph - standard (~> 1.3) yard (~> 0.9) yard-doctest (~> 0.1) diff --git a/Rakefile b/Rakefile index bf8402d..6d35cfd 100644 --- a/Rakefile +++ b/Rakefile @@ -1,16 +1,16 @@ # frozen_string_literal: true -require "bundler/gem_tasks" -require "rspec/core/rake_task" +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) -require "standard/rake" -require "yard/doctest/rake" +require 'standard/rake' +require 'yard/doctest/rake' task default: %i[spec standard] YARD::Doctest::RakeTask.new do |task| task.doctest_opts = %w[-v] - task.pattern = "lib/**/*.rb" + task.pattern = 'lib/**/*.rb' end diff --git a/gemset.nix b/gemset.nix index 5d34e4f..7c1e4d3 100644 --- a/gemset.nix +++ b/gemset.nix @@ -111,16 +111,6 @@ }; version = "3.17.0.4"; }; - lint_roller = { - groups = ["default"]; - platforms = []; - source = { - remotes = ["https://rubygems.org"]; - sha256 = "11yc0d84hsnlvx8cpk4cbj6a4dz9pk0r1k29p0n1fz9acddq831c"; - type = "gem"; - }; - version = "1.1.0"; - }; logger = { groups = ["default"]; platforms = []; @@ -351,17 +341,6 @@ }; version = "1.38.1"; }; - rubocop-performance = { - dependencies = ["rubocop" "rubocop-ast"]; - groups = ["default"]; - platforms = []; - source = { - remotes = ["https://rubygems.org"]; - sha256 = "10hv0lz54q34dlwx6vld0qx1fjskfb0nyb5c18cadrpmjnkqcbzj"; - type = "gem"; - }; - version = "1.23.1"; - }; ruby-progressbar = { groups = ["default"]; platforms = []; @@ -383,39 +362,6 @@ }; version = "0.52.0"; }; - standard = { - dependencies = ["language_server-protocol" "lint_roller" "rubocop" "standard-custom" "standard-performance"]; - groups = ["default"]; - platforms = []; - source = { - remotes = ["https://rubygems.org"]; - sha256 = "13ijzq7r0v0rm1yyba1jfw2s9r3kfxljwypfhzpnkrsag64kk2b5"; - type = "gem"; - }; - version = "1.45.0"; - }; - standard-custom = { - dependencies = ["lint_roller" "rubocop"]; - groups = ["default"]; - platforms = []; - source = { - remotes = ["https://rubygems.org"]; - sha256 = "0av55ai0nv23z5mhrwj1clmxpgyngk7vk6rh58d4y1ws2y2dqjj2"; - type = "gem"; - }; - version = "1.0.2"; - }; - standard-performance = { - dependencies = ["lint_roller" "rubocop-performance"]; - groups = ["default"]; - platforms = []; - source = { - remotes = ["https://rubygems.org"]; - sha256 = "1x298w3wmq8cavbsg903wc3arxp3xh2x8263brvy128436m732rd"; - type = "gem"; - }; - version = "1.6.0"; - }; thor = { groups = ["default"]; platforms = []; diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index d65be91..2f524d6 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true -require_relative "errgonomic/version" +require_relative 'errgonomic/version' # The semantics here borrow heavily from ActiveSupport. Let's prefer that if # loaded, otherwise just copypasta the bits we like. Or convince me to make that # gem a dependency. -if !Object.methods.include?(:blank?) - require_relative "errgonomic/core_ext/blank" -end +require_relative 'errgonomic/core_ext/blank' unless Object.methods.include?(:blank?) +# Introduce certain helper methods into the Object class. +# +# @example +# "foo".result? # => false +# "foo".assert_result! # => raise Errgonomic::ResultRequiredError class Object # Convenience method to indicate whether we are working with a result. # TBD whether we implement some stubs for the rest of the Result API; I want @@ -29,10 +32,16 @@ def result? # Ok("foo").assert_result! # => true def assert_result! return true if result? + raise Errgonomic::ResultRequiredError end end +# Errgonomic adds opinionated abstractions to handle errors in a way that blends +# Rust and Ruby ergonomics. This library leans on Rails conventions for some +# presence-related methods; when in doubt, make those feel like Rails. It also +# has an implementation of Option and Result; when in doubt, make those feel +# more like Rust. module Errgonomic class Error < StandardError; end @@ -49,7 +58,16 @@ class ArgumentError < Error; end class ResultRequiredError < Error; end module Result + # The base class for Result's Ok and Err class variants. We implement as + # much logic as possible here, and let Ok and Err handle their + # initialization and self identification. class Any + attr_reader :value + + def initialize(value) + @value = value + end + # Equality comparison for Result objects is based on value not reference. # # @example @@ -127,11 +145,9 @@ def err_and?(&block) # Ok("foo").unwrap! # => "foo" # Err("foo").unwrap! # => raise Errgonomic::UnwrapError, "value is an Err" def unwrap! - if ok? - @value - else - raise Errgonomic::UnwrapError, "value is an Err" - end + raise Errgonomic::UnwrapError, 'value is an Err' unless ok? + + @value end # Return the inner value of an Ok, else raise an exception with the given @@ -141,11 +157,9 @@ def unwrap! # Ok("foo").expect!("foo") # => "foo" # Err("foo").expect!("foo") # => raise Errgonomic::ExpectError, "foo" def expect!(msg) - if ok? - @value - else - raise Errgonomic::ExpectError, msg - end + raise Errgonomic::ExpectError, msg unless ok? + + @value end # Return the inner value of an Err, else raise an exception when Ok. @@ -154,11 +168,9 @@ def expect!(msg) # Ok("foo").unwrap_err! # => raise Errgonomic::UnwrapError, "value is an Ok" # Err("foo").unwrap_err! # => "foo" def unwrap_err! - if err? - @value - else - raise Errgonomic::UnwrapError, "value is an Ok" - end + raise Errgonomic::UnwrapError, 'value is an Ok' unless err? + + @value end # Given another result, return it if the inner result is Ok, else return @@ -171,8 +183,9 @@ def unwrap_err! # Err("foo").and(Err("bar")) # => Err("foo") # Ok("foo").and("bar") # => raise Errgonomic::ArgumentError, "other must be a Result" def and(other) - raise Errgonomic::ArgumentError, "other must be a Result" unless other.is_a?(Errgonomic::Result::Any) + raise Errgonomic::ArgumentError, 'other must be a Result' unless other.is_a?(Errgonomic::Result::Any) return self if err? + other end @@ -184,10 +197,12 @@ def and(other) def and_then(&block) # raise NotImplementedError, "and_then is not implemented yet" return self if err? + res = block.call(self) unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? - raise Errgonomic::ArgumentError, "and_then block must return a Result" + raise Errgonomic::ArgumentError, 'and_then block must return a Result' end + res end @@ -199,8 +214,12 @@ def and_then(&block) # Err("foo").or(Err("baz")) # => Err("baz") # Err("foo").or("bar") # => raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" def or(other) - raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" unless other.is_a?(Errgonomic::Result::Any) + unless other.is_a?(Errgonomic::Result::Any) + raise Errgonomic::ArgumentError, + 'other must be a Result; you might want unwrap_or' + end return other if err? + self end @@ -216,10 +235,12 @@ def or(other) # Err("foo").or_else { "bar" } # => raise Errgonomic::ArgumentError, "or_else block must return a Result" def or_else(&block) return self if ok? + res = block.call(self) unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? - raise Errgonomic::ArgumentError, "or_else block must return a Result" + raise Errgonomic::ArgumentError, 'or_else block must return a Result' end + res end @@ -230,6 +251,7 @@ def or_else(&block) # Err("foo").unwrap_or("bar") # => "bar" def unwrap_or(other) return value if ok? + other end @@ -241,17 +263,15 @@ def unwrap_or(other) # Err("foo").unwrap_or_else { "bar" } # => "bar" def unwrap_or_else(&block) return value if ok? + block.call(self) end end + # The Ok variant. class Ok < Any attr_accessor :value - def initialize(value) - @value = value - end - # Ok is always ok # # @example @@ -280,7 +300,7 @@ class Arbitrary; end # Err("foo").value # => "foo" # Err().value # => Arbitrary def initialize(value = Arbitrary) - @value = value + super(value) end # Err is always err @@ -333,7 +353,7 @@ def match when Ok @ok_block.call(@result.value) else - raise Errgonomic::MatcherError, "invalid matcher" + raise Errgonomic::MatcherError, 'invalid matcher' end end end @@ -344,6 +364,7 @@ class Any def ==(other) return true if none? && other.none? return true if some? && other.some? && value == other.value + false end @@ -377,6 +398,7 @@ def none_or(&block) # None().to_a # => [] def to_a return [] if none? + [value] end @@ -536,6 +558,7 @@ def ok_or_else(&block) class Some < Any attr_accessor :value + def initialize(value) @value = value end @@ -600,6 +623,7 @@ class Object # @return [Object] The receiver if it is present, otherwise raises a NotPresentError. def present_or_raise(message) raise Errgonomic::NotPresentError, message if blank? + self end @@ -612,7 +636,8 @@ def present_or_raise(message) def present_or(value) # TBD whether this is *too* strict if value.class != self.class && self.class != NilClass - raise Errgonomic::TypeMismatchError, "Type mismatch: default value is a #{value.class} but original was a #{self.class}" + raise Errgonomic::TypeMismatchError, + "Type mismatch: default value is a #{value.class} but original was a #{self.class}" end return self if present? @@ -628,6 +653,7 @@ def present_or(value) # @return [Object] The receiver if it is present, otherwise the result of the block. def present_or_else(&block) return block.call if blank? + self end From cda8361a6b83f3575f6822f20ba1b86340e6615c Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 4 Mar 2025 17:39:23 -0600 Subject: [PATCH 06/20] lint and default rake task --- Rakefile | 8 ++-- spec/errgonomic_spec.rb | 102 ++++++++++++++++++++-------------------- 2 files changed, 54 insertions(+), 56 deletions(-) diff --git a/Rakefile b/Rakefile index 6d35cfd..741248c 100644 --- a/Rakefile +++ b/Rakefile @@ -1,16 +1,14 @@ # frozen_string_literal: true require 'bundler/gem_tasks' -require 'rspec/core/rake_task' +require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) -require 'standard/rake' require 'yard/doctest/rake' - -task default: %i[spec standard] - YARD::Doctest::RakeTask.new do |task| task.doctest_opts = %w[-v] task.pattern = 'lib/**/*.rb' end + +task default: %i[spec yard:doctest] diff --git a/spec/errgonomic_spec.rb b/spec/errgonomic_spec.rb index 643b192..ddde350 100644 --- a/spec/errgonomic_spec.rb +++ b/spec/errgonomic_spec.rb @@ -284,90 +284,90 @@ end end - describe "Option" do - describe "some" do - it "can be created with Some()" do + describe 'Option' do + describe 'some' do + it 'can be created with Some()' do expect(Some(:foo)).to be_a(Errgonomic::Option::Some) end - it "is some" do + it 'is some' do expect(Some(:foo)).to be_some end - it "is not none" do + it 'is not none' do expect(Some(:foo)).not_to be_none end end - describe "None" do - it "can be created with None()" do + describe 'None' do + it 'can be created with None()' do expect(None()).to be_a(Errgonomic::Option::None) end - it "is none" do + it 'is none' do expect(None()).to be_none end - it "is not some" do + it 'is not some' do expect(None()).not_to be_some end end - describe "some_and" do - it "returns true if the option is Some and the block is truthy" do + describe 'some_and' do + it 'returns true if the option is Some and the block is truthy' do expect(Some(:foo).some_and { true }).to be true expect(Some(:foo).some_and { false }).to be false end - it "returns false if the option is None" do + it 'returns false if the option is None' do expect(None().some_and { true }).to be false expect(None().some_and { false }).to be false end end - describe "none_or" do - it "returns true if the option is None" do + describe 'none_or' do + it 'returns true if the option is None' do expect(None().none_or { true }).to be true expect(None().none_or { false }).to be true end - it "returns true if the block is truthy" do + it 'returns true if the block is truthy' do expect(Some(:foo).none_or { true }).to be true expect(Some(:foo).none_or { false }).to be false end end - describe "to_a" do - it "returns an array of the contained value, if any" do + describe 'to_a' do + it 'returns an array of the contained value, if any' do expect(Some(:foo).to_a).to eq([:foo]) expect(None().to_a).to eq([]) end end - describe "expect!" do - it "returns the inner value or else raises an error with the given message" do - expect(Some(:foo).expect!("msg")).to eq(:foo) - expect { None().expect!("msg") }.to raise_error(Errgonomic::ExpectError, "msg") + describe 'expect!' do + it 'returns the inner value or else raises an error with the given message' do + expect(Some(:foo).expect!('msg')).to eq(:foo) + expect { None().expect!('msg') }.to raise_error(Errgonomic::ExpectError, 'msg') end end - describe "unwrap!" do - it "returns the inner value or else raises an error" do - expect(Some(:foo).unwrap!("foo")).to eq(:foo) - expect { None().unwrap!("foo") }.to raise_error(Errgonomic::UnwrapError) + describe 'unwrap!' do + it 'returns the inner value or else raises an error' do + expect(Some(:foo).unwrap!).to eq(:foo) + expect { None().unwrap! }.to raise_error(Errgonomic::UnwrapError) end end - describe "unwrap_or" do - it "returns the inner value if present, or the provided value" do + describe 'unwrap_or' do + it 'returns the inner value if present, or the provided value' do expect(Some(:foo).unwrap_or(:bar)).to eq(:foo) expect(None().unwrap_or(:bar)).to eq(:bar) end end - describe "unwrap_or_else" do - it "returns the inner value if present, or the result of the provided block" do + describe 'unwrap_or_else' do + it 'returns the inner value if present, or the result of the provided block' do expect(Some(:foo).unwrap_or_else { :bar }).to eq(:foo) expect(None().unwrap_or_else { :bar }).to eq(:bar) end end - describe "tap_some" do - it "calls the block with the inner value, if some, returning the original Option" do + describe 'tap_some' do + it 'calls the block with the inner value, if some, returning the original Option' do option = Some(:foo) tapped = false expect(option.tap_some { |v| tapped = true }).to eq(option) @@ -379,39 +379,39 @@ end end - describe "map" + describe 'map' - describe "map_or" do - it "returns the provided result (if None) or applies a function to the contained value (if Some)" do + describe 'map_or' do + it 'returns the provided result (if None) or applies a function to the contained value (if Some)' do option = Some(0) val = option.map_or(100) { |v| v + 1 } expect(val).to eq(1) end end - describe "map_or_else" + describe 'map_or_else' - describe "ok_or" - describe "ok_or_else" + describe 'ok_or' + describe 'ok_or_else' - describe "and" - describe "and_then" + describe 'and' + describe 'and_then' - describe "filter" + describe 'filter' - describe "or" - describe "or_else" - describe "xor" + describe 'or' + describe 'or_else' + describe 'xor' - describe "insert" - describe "get_or_insert" - describe "get_or_insert_with" + describe 'insert' + describe 'get_or_insert' + describe 'get_or_insert_with' - describe "take" - describe "take_if" - describe "replace" + describe 'take' + describe 'take_if' + describe 'replace' - describe "zip" - describe "zip_with" + describe 'zip' + describe 'zip_with' end end From 3b35cf3252e2ed914e70657362e37db9e5a69a8b Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 10:04:07 -0500 Subject: [PATCH 07/20] Reorganize files; get rid of the Result#match DSL. --- lib/errgonomic.rb | 664 +------------------------------------ lib/errgonomic/option.rb | 236 +++++++++++++ lib/errgonomic/presence.rb | 83 +++++ lib/errgonomic/result.rb | 288 ++++++++++++++++ 4 files changed, 616 insertions(+), 655 deletions(-) create mode 100644 lib/errgonomic/option.rb create mode 100644 lib/errgonomic/presence.rb create mode 100644 lib/errgonomic/result.rb diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 2f524d6..60910c6 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -1,41 +1,15 @@ # frozen_string_literal: true -require_relative 'errgonomic/version' - -# The semantics here borrow heavily from ActiveSupport. Let's prefer that if -# loaded, otherwise just copypasta the bits we like. Or convince me to make that -# gem a dependency. -require_relative 'errgonomic/core_ext/blank' unless Object.methods.include?(:blank?) +unless defined?(Errgonomic::VERSION) + require_relative 'errgonomic/version' +end -# Introduce certain helper methods into the Object class. -# -# @example -# "foo".result? # => false -# "foo".assert_result! # => raise Errgonomic::ResultRequiredError -class Object - # Convenience method to indicate whether we are working with a result. - # TBD whether we implement some stubs for the rest of the Result API; I want - # to think about how effectively these map to truthiness or presence. - # - # @example - # "foo".result? # => false - # Ok("foo").result? # => true - def result? - false - end +# A more opinionated blend with Rails presence. +require_relative 'errgonomic/presence' - # Lacking static typing, we are going to want to make it easy to enforce at - # runtime that a given object is a Result. - # - # @example - # "foo".assert_result! # => raise Errgonomic::ResultRequiredError - # Ok("foo").assert_result! # => true - def assert_result! - return true if result? - - raise Errgonomic::ResultRequiredError - end -end +# Bring in our Option and Result. +require_relative 'errgonomic/option' +require_relative 'errgonomic/result' # Errgonomic adds opinionated abstractions to handle errors in a way that blends # Rust and Ruby ergonomics. This library leans on Rails conventions for some @@ -57,532 +31,7 @@ class ArgumentError < Error; end class ResultRequiredError < Error; end - module Result - # The base class for Result's Ok and Err class variants. We implement as - # much logic as possible here, and let Ok and Err handle their - # initialization and self identification. - class Any - attr_reader :value - - def initialize(value) - @value = value - end - - # Equality comparison for Result objects is based on value not reference. - # - # @example - # Ok("foo") == Ok("foo") # => true - # Ok("foo") == Err("foo") # => false - # Ok("foo").object_id != Ok("foo").object_id # => true - def ==(other) - self.class == other.class && value == other.value - end - - # Indicate that this is some kind of result object. Contrast to - # Object#result? which is false for all other types. - # @example - # Ok("foo").result? # => true - # Err("foo").result? # => true - # "foo".result? # => false - def result? - true - end - - # A lightweight DSL to invoke code for a result based on whether it is an - # Ok or Err. - # - # @example - # Ok("foo").match do - # ok { :foo } - # err { :bar } - # end # => :foo - # - # Err("foo").match do - # ok { :foo } - # err { :bar } - # end # => :bar - def match(&block) - matcher = Matcher.new(self) - matcher.instance_eval(&block) - matcher.match - end - - # Return true if the inner value is an Ok and the result of the block is - # truthy. - # - # @example - # Ok("foo").ok_and? { |_| true } # => true - # Ok("foo").ok_and? { |_| false } # => false - # Err("foo").ok_and? { |_| true } # => false - # Err("foo").ok_and? { |_| false } # => false - def ok_and?(&block) - if ok? - !!block.call(value) - else - false - end - end - - # Return true if the inner value is an Err and the result of the block is - # truthy. - # - # @example - # Ok("foo").err_and? { |_| true } # => false - # Ok("foo").err_and? { |_| false } # => false - # Err("foo").err_and? { |_| true } # => true - # Err("foo").err_and? { |_| false } # => false - def err_and?(&block) - if err? - !!block.call(value) - else - false - end - end - - # Return the inner value of an Ok, else raise an exception when Err. - # - # @example - # Ok("foo").unwrap! # => "foo" - # Err("foo").unwrap! # => raise Errgonomic::UnwrapError, "value is an Err" - def unwrap! - raise Errgonomic::UnwrapError, 'value is an Err' unless ok? - - @value - end - - # Return the inner value of an Ok, else raise an exception with the given - # message when Err. - # - # @example - # Ok("foo").expect!("foo") # => "foo" - # Err("foo").expect!("foo") # => raise Errgonomic::ExpectError, "foo" - def expect!(msg) - raise Errgonomic::ExpectError, msg unless ok? - - @value - end - - # Return the inner value of an Err, else raise an exception when Ok. - # - # @example - # Ok("foo").unwrap_err! # => raise Errgonomic::UnwrapError, "value is an Ok" - # Err("foo").unwrap_err! # => "foo" - def unwrap_err! - raise Errgonomic::UnwrapError, 'value is an Ok' unless err? - - @value - end - - # Given another result, return it if the inner result is Ok, else return - # the inner Err. Raise an exception if the other value is not a Result. - # - # @example - # Ok("foo").and(Ok("bar")) # => Ok("bar") - # Ok("foo").and(Err("bar")) # => Err("bar") - # Err("foo").and(Ok("bar")) # => Err("foo") - # Err("foo").and(Err("bar")) # => Err("foo") - # Ok("foo").and("bar") # => raise Errgonomic::ArgumentError, "other must be a Result" - def and(other) - raise Errgonomic::ArgumentError, 'other must be a Result' unless other.is_a?(Errgonomic::Result::Any) - return self if err? - - other - end - - # Given a block, evaluate it and return its result if the inner result is - # Ok, else return the inner Err. This is lazy evaluated, and we - # pedantically check the type of the block's return value at runtime. This - # is annoying, sorry, but better than an "undefined method" error. - # Hopefully it gives your test suite a chance to detect incorrect usage. - def and_then(&block) - # raise NotImplementedError, "and_then is not implemented yet" - return self if err? - - res = block.call(self) - unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? - raise Errgonomic::ArgumentError, 'and_then block must return a Result' - end - - res - end - - # Return other if self is Err, else return the original Option. Raises a - # pedantic runtime exception if other is not a Result. - # - # @example - # Err("foo").or(Ok("bar")) # => Ok("bar") - # Err("foo").or(Err("baz")) # => Err("baz") - # Err("foo").or("bar") # => raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" - def or(other) - unless other.is_a?(Errgonomic::Result::Any) - raise Errgonomic::ArgumentError, - 'other must be a Result; you might want unwrap_or' - end - return other if err? - - self - end - - # Return self if it is Ok, else lazy evaluate the block and return its - # result. Raises a pedantic runtime check that the block returns a Result. - # Sorry about that, hopefully it helps your tests. Better than ambiguous - # downstream "undefined method" errors, probably. - # - # @example - # Ok("foo").or_else { Ok("bar") } # => Ok("foo") - # Err("foo").or_else { Ok("bar") } # => Ok("bar") - # Err("foo").or_else { Err("baz") } # => Err("baz") - # Err("foo").or_else { "bar" } # => raise Errgonomic::ArgumentError, "or_else block must return a Result" - def or_else(&block) - return self if ok? - - res = block.call(self) - unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? - raise Errgonomic::ArgumentError, 'or_else block must return a Result' - end - - res - end - - # Return the inner value if self is Ok, else return the provided default. - # - # @example - # Ok("foo").unwrap_or("bar") # => "foo" - # Err("foo").unwrap_or("bar") # => "bar" - def unwrap_or(other) - return value if ok? - - other - end - - # Return the inner value if self is Ok, else lazy evaluate the block and - # return its result. - # - # @example - # Ok("foo").unwrap_or_else { "bar" } # => "foo" - # Err("foo").unwrap_or_else { "bar" } # => "bar" - def unwrap_or_else(&block) - return value if ok? - - block.call(self) - end - end - - # The Ok variant. - class Ok < Any - attr_accessor :value - - # Ok is always ok - # - # @example - # Ok("foo").ok? # => true - def ok? - true - end - - # Ok is never err - # - # @example - # Ok("foo").err? # => false - def err? - false - end - end - - class Err < Any - class Arbitrary; end - - attr_accessor :value - - # Err may be constructed without a value, if you want. - # - # @example - # Err("foo").value # => "foo" - # Err().value # => Arbitrary - def initialize(value = Arbitrary) - super(value) - end - - # Err is always err - # - # @example - # Err("foo").err? # => true - def err? - true - end - - # Err is never ok - # - # @example - # Err("foo").ok? # => false - def ok? - false - end - end - - # This is my first stab at a basic DSL for matching and responding to - # different Result variants. - # - # @example - # Err("foo").match do - # ok { :ok } - # err { :err } - # end # => :err - # - # Ok("foo").match do - # ok { :ok } - # err { :err } - # end # => :ok - class Matcher - def initialize(result) - @result = result - end - - def ok(&block) - @ok_block = block - end - - def err(&block) - @err_block = block - end - - def match - case @result - when Err - @err_block.call(@result.value) - when Ok - @ok_block.call(@result.value) - else - raise Errgonomic::MatcherError, 'invalid matcher' - end - end - end - end - - module Option - class Any - def ==(other) - return true if none? && other.none? - return true if some? && other.some? && value == other.value - - false - end - - # return true if the contained value is Some and the block returns truthy - # - # @example - # Some(1).some_and { |x| x > 0 } # => true - # Some(0).some_and { |x| x > 0 } # => false - # None().some_and { |x| x > 0 } # => false - def some_and(&block) - return false if none? - - !!block.call(value) - end - - # return true if the contained value is None or the block returns truthy - # - # @example - # None().none_or { false } # => true - # Some(1).none_or { |x| x > 0 } # => true - # Some(1).none_or { |x| x < 0 } # => false - def none_or(&block) - return true if none? - - !!block.call(value) - end - - # return an Array with the contained value, if any - # @example - # Some(1).to_a # => [1] - # None().to_a # => [] - def to_a - return [] if none? - - [value] - end - - # returns the inner value if present, else raises an error - # @example - # Some(1).unwrap! # => 1 - # None().unwrap! # => raise Errgonomic::UnwrapError, "cannot unwrap None" - def unwrap! - raise Errgonomic::UnwrapError, 'cannot unwrap None' if none? - - value - end - - # returns the inner value if pressent, else raises an error with the given - # message - # @example - # Some(1).expect!("msg") # => 1 - # None().expect!("msg") # => raise Errgonomic::ExpectError, "msg" - def expect!(msg) - raise Errgonomic::ExpectError, msg if none? - - value - end - - # returns the inner value if present, else returns the default value - # @example - # Some(1).unwrap_or(2) # => 1 - # None().unwrap_or(2) # => 2 - def unwrap_or(default) - return default if none? - - value - end - - # returns the inner value if present, else returns the result of the - # provided block - # @example - # Some(1).unwrap_or_else { 2 } # => 1 - # None().unwrap_or_else { 2 } # => 2 - def unwrap_or_else(&block) - return block.call if none? - - value - end - - # Calls a function with the inner value, if Some, but returns the original - # option. In Rust, this is "inspect" but that clashes with Ruby - # conventions. We call this "tap_some" to avoid further clashing with - # "tap." - # - # @example - # tapped = false - # Some(1).tap_some { |x| tapped = x } # => Some(1) - # tapped # => 1 - # tapped = false - # None().tap_some { tapped = true } # => None() - # tapped # => false - def tap_some(&block) - block.call(value) if some? - self - 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. - # @example - # Some(1).map { |x| Some(x + 1) } # => Some(2) - # Some(1).map { |x| None() } # => None() - # None().map { Some(1) } # => None() - # Some(1).map { :foo } # => raise Errgonomic::ArgumentError, "block must return an Option" - def map(&block) - return self if none? - - res = block.call(value) - unless res.is_a?(Errgonomic::Option::Any) || Errgonomic.give_me_ambiguous_downstream_errors? - raise ArgumentError, 'block must return an Option' - end - - res - 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+. - # - # @example - # None().map_or(1) { 100 } # => 1 - # Some(1).map_or(1) { |x| x + 1 } # => 2 - # Some("foo").map_or(0) { |str| str.length } # => 3 - def map_or(default, &block) - return default if none? - - block.call(value) - end - - # Computes a default from the given Proc if None, or applies the block to - # the contained value (if Some). - # - # @example - # None().map_or_else(-> { :foo }) { :bar } # => :foo - # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3 - def map_or_else(proc, &block) - return proc.call if none? - - block.call(value) - end - - # convert the option into a result where Some is Ok and None is Err - # @example - # None().ok # => Err() - # Some(1).ok # => Ok(1) - def ok - return Errgonomic::Result::Ok.new(value) if some? - - Errgonomic::Result::Err.new - end - - # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err) - # - # @example - # None().ok_or("wow") # => Err("wow") - # Some(1).ok_or("such err") # => Ok(1) - def ok_or(err) - return Errgonomic::Result::Ok.new(value) if some? - - Errgonomic::Result::Err.new(err) - end - - # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err). - # TODO: block or proc? - # - # @example - # None().ok_or_else { "wow" } # => Err("wow") - # Some("foo").ok_or_else { "such err" } # => Ok("foo") - def ok_or_else(&block) - return Errgonomic::Result::Ok.new(value) if some? - - Errgonomic::Result::Err.new(block.call) - end - - # TODO: - # and - # and_then - # filter - # or - # or_else - # xor - # insert - # get_or_insert - # get_or_insert_with - # take - # take_if - # replace - # zip - # zip_with - end - - class Some < Any - attr_accessor :value - - def initialize(value) - @value = value - end - - def some? - true - end - - def none? - false - end - end - - class None < Any - def some? - false - end - - def none? - true - end - end - end - + # A little bit of control over how pedantic we are in our runtime type checks. def self.give_me_ambiguous_downstream_errors? @give_me_ambiguous_downstream_errors ||= false end @@ -597,98 +46,3 @@ def self.with_ambiguous_downstream_errors(&block) @give_me_ambiguous_downstream_errors = original_value end end - -def Ok(value) - Errgonomic::Result::Ok.new(value) -end - -def Err(value = Errgonomic::Result::Err::Arbitrary) - Errgonomic::Result::Err.new(value) -end - -def Some(value) - Errgonomic::Option::Some.new(value) -end - -def None - Errgonomic::Option::None.new -end - -class Object - # Returns the receiver if it is present, otherwise raises a NotPresentError. - # This method is useful to enforce strong expectations, where it is preferable - # to fail early rather than risk causing an ambiguous error somewhere else. - # - # @param message [String] The error message to raise if the receiver is not present. - # @return [Object] The receiver if it is present, otherwise raises a NotPresentError. - def present_or_raise(message) - raise Errgonomic::NotPresentError, message if blank? - - self - end - - # Returns the receiver if it is present, otherwise returns the given value. If - # constructing the default value is expensive, consider using - # +present_or_else+. - # - # @param value [Object] The value to return if the receiver is not present. - # @return [Object] The receiver if it is present, otherwise the given value. - def present_or(value) - # TBD whether this is *too* strict - if value.class != self.class && self.class != NilClass - raise Errgonomic::TypeMismatchError, - "Type mismatch: default value is a #{value.class} but original was a #{self.class}" - end - - return self if present? - - value - end - - # Returns the receiver if it is present, otherwise returns the result of the - # block. Invoking a block may be preferable to returning a default value with - # +present_or+, if constructing the default value is expensive. - # - # @param block [Proc] The block to call if the receiver is not present. - # @return [Object] The receiver if it is present, otherwise the result of the block. - def present_or_else(&block) - return block.call if blank? - - self - end - - # Returns the receiver if it is blank, otherwise raises a NotPresentError. - # This method is helpful to enforce expectations where blank objects are required. - # - # @param message [String] The error message to raise if the receiver is not blank. - # @return [Object] The receiver if it is blank, otherwise raises a NotPresentError. - def blank_or_raise(message) - raise Errgonomic::NotPresentError, message unless blank? - self - end - - # Returns the receiver if it is blank, otherwise returns the given value. - # - # @param value [Object] The value to return if the receiver is not blank. - # @return [Object] The receiver if it is blank, otherwise the given value. - def blank_or(value) - # TBD whether this is *too* strict - if value.class != self.class && self.class != NilClass - raise Errgonomic::TypeMismatchError, "Type mismatch: default value is a #{value.class} but original was a #{self.class}" - end - - return self if blank? - - value - end - - # Returns the receiver if it is blank, otherwise returns the result of the - # block. - # - # @param block [Proc] The block to call if the receiver is not blank. - # @return [Object] The receiver if it is blank, otherwise the result of the block. - def blank_or_else(&block) - return block.call unless blank? - self - end -end diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb new file mode 100644 index 0000000..d3d9fda --- /dev/null +++ b/lib/errgonomic/option.rb @@ -0,0 +1,236 @@ +module Errgonomic + module Option + class Any + def ==(other) + return true if none? && other.none? + return true if some? && other.some? && value == other.value + + false + end + + # return true if the contained value is Some and the block returns truthy + # + # @example + # Some(1).some_and { |x| x > 0 } # => true + # Some(0).some_and { |x| x > 0 } # => false + # None().some_and { |x| x > 0 } # => false + def some_and(&block) + return false if none? + + !!block.call(value) + end + + # return true if the contained value is None or the block returns truthy + # + # @example + # None().none_or { false } # => true + # Some(1).none_or { |x| x > 0 } # => true + # Some(1).none_or { |x| x < 0 } # => false + def none_or(&block) + return true if none? + + !!block.call(value) + end + + # return an Array with the contained value, if any + # @example + # Some(1).to_a # => [1] + # None().to_a # => [] + def to_a + return [] if none? + + [value] + end + + # returns the inner value if present, else raises an error + # @example + # Some(1).unwrap! # => 1 + # None().unwrap! # => raise Errgonomic::UnwrapError, "cannot unwrap None" + def unwrap! + raise Errgonomic::UnwrapError, 'cannot unwrap None' if none? + + value + end + + # returns the inner value if pressent, else raises an error with the given + # message + # @example + # Some(1).expect!("msg") # => 1 + # None().expect!("msg") # => raise Errgonomic::ExpectError, "msg" + def expect!(msg) + raise Errgonomic::ExpectError, msg if none? + + value + end + + # returns the inner value if present, else returns the default value + # @example + # Some(1).unwrap_or(2) # => 1 + # None().unwrap_or(2) # => 2 + def unwrap_or(default) + return default if none? + + value + end + + # returns the inner value if present, else returns the result of the + # provided block + # @example + # Some(1).unwrap_or_else { 2 } # => 1 + # None().unwrap_or_else { 2 } # => 2 + def unwrap_or_else(&block) + return block.call if none? + + value + end + + # Calls a function with the inner value, if Some, but returns the original + # option. In Rust, this is "inspect" but that clashes with Ruby + # conventions. We call this "tap_some" to avoid further clashing with + # "tap." + # + # @example + # tapped = false + # Some(1).tap_some { |x| tapped = x } # => Some(1) + # tapped # => 1 + # tapped = false + # None().tap_some { tapped = true } # => None() + # tapped # => false + def tap_some(&block) + block.call(value) if some? + self + 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. + # @example + # Some(1).map { |x| Some(x + 1) } # => Some(2) + # Some(1).map { |x| None() } # => None() + # None().map { Some(1) } # => None() + # Some(1).map { :foo } # => raise Errgonomic::ArgumentError, "block must return an Option" + def map(&block) + return self if none? + + res = block.call(value) + unless res.is_a?(Errgonomic::Option::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + raise ArgumentError, 'block must return an Option' + end + + res + 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+. + # + # @example + # None().map_or(1) { 100 } # => 1 + # Some(1).map_or(1) { |x| x + 1 } # => 2 + # Some("foo").map_or(0) { |str| str.length } # => 3 + def map_or(default, &block) + return default if none? + + block.call(value) + end + + # Computes a default from the given Proc if None, or applies the block to + # the contained value (if Some). + # + # @example + # None().map_or_else(-> { :foo }) { :bar } # => :foo + # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3 + def map_or_else(proc, &block) + return proc.call if none? + + block.call(value) + end + + # convert the option into a result where Some is Ok and None is Err + # @example + # None().ok # => Err() + # Some(1).ok # => Ok(1) + def ok + return Errgonomic::Result::Ok.new(value) if some? + + Errgonomic::Result::Err.new + end + + # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err) + # + # @example + # None().ok_or("wow") # => Err("wow") + # Some(1).ok_or("such err") # => Ok(1) + def ok_or(err) + return Errgonomic::Result::Ok.new(value) if some? + + Errgonomic::Result::Err.new(err) + end + + # Transforms the option into a result, mapping Some(v) to Ok(v) and None to Err(err). + # TODO: block or proc? + # + # @example + # None().ok_or_else { "wow" } # => Err("wow") + # Some("foo").ok_or_else { "such err" } # => Ok("foo") + def ok_or_else(&block) + return Errgonomic::Result::Ok.new(value) if some? + + Errgonomic::Result::Err.new(block.call) + end + + # TODO: + # and + # and_then + # filter + # or + # or_else + # xor + # insert + # get_or_insert + # get_or_insert_with + # take + # take_if + # replace + # zip + # zip_with + end + + class Some < Any + attr_accessor :value + + def initialize(value) + @value = value + end + + def some? + true + end + + def none? + false + end + end + + class None < Any + def some? + false + end + + def none? + true + end + end + end + +end + +# Global convenience for constructing a Some value. +def Some(value) + Errgonomic::Option::Some.new(value) +end + +# Global convenience for constructing a None value. +def None + Errgonomic::Option::None.new +end diff --git a/lib/errgonomic/presence.rb b/lib/errgonomic/presence.rb new file mode 100644 index 0000000..3ac700a --- /dev/null +++ b/lib/errgonomic/presence.rb @@ -0,0 +1,83 @@ +# The semantics here borrow heavily from ActiveSupport. Let's prefer that if +# loaded, otherwise just copypasta the bits we like. Or convince me to make that +# gem a dependency. +require_relative './core_ext/blank' unless Object.methods.include?(:blank?) + +class Object + # Returns the receiver if it is present, otherwise raises a NotPresentError. + # This method is useful to enforce strong expectations, where it is preferable + # to fail early rather than risk causing an ambiguous error somewhere else. + # + # @param message [String] The error message to raise if the receiver is not present. + # @return [Object] The receiver if it is present, otherwise raises a NotPresentError. + def present_or_raise(message) + raise Errgonomic::NotPresentError, message if blank? + + self + end + + # Returns the receiver if it is present, otherwise returns the given value. If + # constructing the default value is expensive, consider using + # +present_or_else+. + # + # @param value [Object] The value to return if the receiver is not present. + # @return [Object] The receiver if it is present, otherwise the given value. + def present_or(value) + # TBD whether this is *too* strict + if value.class != self.class && self.class != NilClass + raise Errgonomic::TypeMismatchError, + "Type mismatch: default value is a #{value.class} but original was a #{self.class}" + end + + return self if present? + + value + end + + # Returns the receiver if it is present, otherwise returns the result of the + # block. Invoking a block may be preferable to returning a default value with + # +present_or+, if constructing the default value is expensive. + # + # @param block [Proc] The block to call if the receiver is not present. + # @return [Object] The receiver if it is present, otherwise the result of the block. + def present_or_else(&block) + return block.call if blank? + + self + end + + # Returns the receiver if it is blank, otherwise raises a NotPresentError. + # This method is helpful to enforce expectations where blank objects are required. + # + # @param message [String] The error message to raise if the receiver is not blank. + # @return [Object] The receiver if it is blank, otherwise raises a NotPresentError. + def blank_or_raise(message) + raise Errgonomic::NotPresentError, message unless blank? + self + end + + # Returns the receiver if it is blank, otherwise returns the given value. + # + # @param value [Object] The value to return if the receiver is not blank. + # @return [Object] The receiver if it is blank, otherwise the given value. + def blank_or(value) + # TBD whether this is *too* strict + if value.class != self.class && self.class != NilClass + raise Errgonomic::TypeMismatchError, "Type mismatch: default value is a #{value.class} but original was a #{self.class}" + end + + return self if blank? + + value + end + + # Returns the receiver if it is blank, otherwise returns the result of the + # block. + # + # @param block [Proc] The block to call if the receiver is not blank. + # @return [Object] The receiver if it is blank, otherwise the result of the block. + def blank_or_else(&block) + return block.call unless blank? + self + end +end diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb new file mode 100644 index 0000000..c56d3bd --- /dev/null +++ b/lib/errgonomic/result.rb @@ -0,0 +1,288 @@ +module Errgonomic + module Result + # The base class for Result's Ok and Err class variants. We implement as + # much logic as possible here, and let Ok and Err handle their + # initialization and self identification. + class Any + attr_reader :value + + def initialize(value) + @value = value + end + + # Equality comparison for Result objects is based on value not reference. + # + # @example + # Ok("foo") == Ok("foo") # => true + # Ok("foo") == Err("foo") # => false + # Ok("foo").object_id != Ok("foo").object_id # => true + def ==(other) + self.class == other.class && value == other.value + end + + # Indicate that this is some kind of result object. Contrast to + # Object#result? which is false for all other types. + # @example + # Ok("foo").result? # => true + # Err("foo").result? # => true + # "foo".result? # => false + def result? + true + end + + # Return true if the inner value is an Ok and the result of the block is + # truthy. + # + # @example + # Ok("foo").ok_and? { |_| true } # => true + # Ok("foo").ok_and? { |_| false } # => false + # Err("foo").ok_and? { |_| true } # => false + # Err("foo").ok_and? { |_| false } # => false + def ok_and?(&block) + if ok? + !!block.call(value) + else + false + end + end + + # Return true if the inner value is an Err and the result of the block is + # truthy. + # + # @example + # Ok("foo").err_and? { |_| true } # => false + # Ok("foo").err_and? { |_| false } # => false + # Err("foo").err_and? { |_| true } # => true + # Err("foo").err_and? { |_| false } # => false + def err_and?(&block) + if err? + !!block.call(value) + else + false + end + end + + # Return the inner value of an Ok, else raise an exception when Err. + # + # @example + # Ok("foo").unwrap! # => "foo" + # Err("foo").unwrap! # => raise Errgonomic::UnwrapError, "value is an Err" + def unwrap! + raise Errgonomic::UnwrapError, 'value is an Err' unless ok? + + @value + end + + # Return the inner value of an Ok, else raise an exception with the given + # message when Err. + # + # @example + # Ok("foo").expect!("foo") # => "foo" + # Err("foo").expect!("foo") # => raise Errgonomic::ExpectError, "foo" + def expect!(msg) + raise Errgonomic::ExpectError, msg unless ok? + + @value + end + + # Return the inner value of an Err, else raise an exception when Ok. + # + # @example + # Ok("foo").unwrap_err! # => raise Errgonomic::UnwrapError, "value is an Ok" + # Err("foo").unwrap_err! # => "foo" + def unwrap_err! + raise Errgonomic::UnwrapError, 'value is an Ok' unless err? + + @value + end + + # Given another result, return it if the inner result is Ok, else return + # the inner Err. Raise an exception if the other value is not a Result. + # + # @example + # Ok("foo").and(Ok("bar")) # => Ok("bar") + # Ok("foo").and(Err("bar")) # => Err("bar") + # Err("foo").and(Ok("bar")) # => Err("foo") + # Err("foo").and(Err("bar")) # => Err("foo") + # Ok("foo").and("bar") # => raise Errgonomic::ArgumentError, "other must be a Result" + def and(other) + raise Errgonomic::ArgumentError, 'other must be a Result' unless other.is_a?(Errgonomic::Result::Any) + return self if err? + + other + end + + # Given a block, evaluate it and return its result if the inner result is + # Ok, else return the inner Err. This is lazy evaluated, and we + # pedantically check the type of the block's return value at runtime. This + # is annoying, sorry, but better than an "undefined method" error. + # Hopefully it gives your test suite a chance to detect incorrect usage. + def and_then(&block) + # raise NotImplementedError, "and_then is not implemented yet" + return self if err? + + res = block.call(self) + unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + raise Errgonomic::ArgumentError, 'and_then block must return a Result' + end + + res + end + + # Return other if self is Err, else return the original Option. Raises a + # pedantic runtime exception if other is not a Result. + # + # @example + # Err("foo").or(Ok("bar")) # => Ok("bar") + # Err("foo").or(Err("baz")) # => Err("baz") + # Err("foo").or("bar") # => raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" + def or(other) + unless other.is_a?(Errgonomic::Result::Any) + raise Errgonomic::ArgumentError, + 'other must be a Result; you might want unwrap_or' + end + return other if err? + + self + end + + # Return self if it is Ok, else lazy evaluate the block and return its + # result. Raises a pedantic runtime check that the block returns a Result. + # Sorry about that, hopefully it helps your tests. Better than ambiguous + # downstream "undefined method" errors, probably. + # + # @example + # Ok("foo").or_else { Ok("bar") } # => Ok("foo") + # Err("foo").or_else { Ok("bar") } # => Ok("bar") + # Err("foo").or_else { Err("baz") } # => Err("baz") + # Err("foo").or_else { "bar" } # => raise Errgonomic::ArgumentError, "or_else block must return a Result" + def or_else(&block) + return self if ok? + + res = block.call(self) + unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + raise Errgonomic::ArgumentError, 'or_else block must return a Result' + end + + res + end + + # Return the inner value if self is Ok, else return the provided default. + # + # @example + # Ok("foo").unwrap_or("bar") # => "foo" + # Err("foo").unwrap_or("bar") # => "bar" + def unwrap_or(other) + return value if ok? + + other + end + + # Return the inner value if self is Ok, else lazy evaluate the block and + # return its result. + # + # @example + # Ok("foo").unwrap_or_else { "bar" } # => "foo" + # Err("foo").unwrap_or_else { "bar" } # => "bar" + def unwrap_or_else(&block) + return value if ok? + + block.call(self) + end + end + + # The Ok variant. + class Ok < Any + attr_accessor :value + + # Ok is always ok + # + # @example + # Ok("foo").ok? # => true + def ok? + true + end + + # Ok is never err + # + # @example + # Ok("foo").err? # => false + def err? + false + end + end + + class Err < Any + class Arbitrary; end + + attr_accessor :value + + # Err may be constructed without a value, if you want. + # + # @example + # Err("foo").value # => "foo" + # Err().value # => Arbitrary + def initialize(value = Arbitrary) + super(value) + end + + # Err is always err + # + # @example + # Err("foo").err? # => true + def err? + true + end + + # Err is never ok + # + # @example + # Err("foo").ok? # => false + def ok? + false + end + end + + end +end + +# Introduce certain helper methods into the Object class. +# +# @example +# "foo".result? # => false +# "foo".assert_result! # => raise Errgonomic::ResultRequiredError +class Object + # Convenience method to indicate whether we are working with a result. + # TBD whether we implement some stubs for the rest of the Result API; I want + # to think about how effectively these map to truthiness or presence. + # + # @example + # "foo".result? # => false + # Ok("foo").result? # => true + def result? + false + end + + # Lacking static typing, we are going to want to make it easy to enforce at + # runtime that a given object is a Result. + # + # @example + # "foo".assert_result! # => raise Errgonomic::ResultRequiredError + # Ok("foo").assert_result! # => true + def assert_result! + return true if result? + + raise Errgonomic::ResultRequiredError + end +end + + +# Global convenience method for constructing an Ok result. +def Ok(value) + Errgonomic::Result::Ok.new(value) +end + +# Global convenience method for constructing an Err result. +def Err(value = Errgonomic::Result::Err::Arbitrary) + Errgonomic::Result::Err.new(value) +end From 8c91fe774d0de56504d2885aed84638598dd7a5c Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 10:06:57 -0500 Subject: [PATCH 08/20] We're ready for a version bump. --- lib/errgonomic/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index a2389e7..2d3ef3a 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = "0.1.0" + VERSION = "0.2.0" end From 784d740c7e4475183c969d646e803ff48b267d7f Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 10:10:40 -0500 Subject: [PATCH 09/20] some nix store silliness --- errgonomic.gemspec | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/errgonomic.gemspec b/errgonomic.gemspec index f53492f..55a3eea 100644 --- a/errgonomic.gemspec +++ b/errgonomic.gemspec @@ -1,8 +1,7 @@ # frozen_string_literal: true # when we build in the nix store, version.rb is hashed and adjacent to the gemspec -if __FILE__.include?("/nix/store") - version_file = Dir.glob("./*-version.rb").first +if __FILE__.include?("/nix/store") && (version_file = Dir.glob("./*-version.rb").first) require_relative version_file else require_relative "lib/errgonomic/version" From 8a331beb5998b9da38bc8fe32326aa897ddc6df9 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 10:27:16 -0500 Subject: [PATCH 10/20] Improve comparability test, and start with something strict --- lib/errgonomic.rb | 2 ++ lib/errgonomic/option.rb | 20 +++++++++++++++++--- lib/errgonomic/result.rb | 5 ++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 60910c6..e80c6d6 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -31,6 +31,8 @@ class ArgumentError < Error; end class ResultRequiredError < Error; end + class NotComparableError < StandardError; end + # A little bit of control over how pedantic we are in our runtime type checks. def self.give_me_ambiguous_downstream_errors? @give_me_ambiguous_downstream_errors ||= false diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index d3d9fda..e795cda 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -1,11 +1,25 @@ module Errgonomic module Option + + class Any + # An option of the same type with an equal inner value is equal. + # + # TODO: we're going to monkey patch this into Rails, so we might want to + # allow spooky Some(T) == T comparability. + # + # @example + # Some(1) == Some(1) # => true + # Some(1) == Some(2) # => false + # Some(1) == None() # => false + # None() == None() # => true + # Some(1) == 1 # => raise Errgonomic::NotComparableError, "Cannot compare Errgonomic::Option::Some with Integer" def ==(other) - return true if none? && other.none? - return true if some? && other.some? && value == other.value + raise NotComparableError, "Cannot compare #{self.class} with #{other.class}" unless other.is_a?(Any) + return false if self.class != other.class + return true if none? - false + value == other.value end # return true if the contained value is Some and the block returns truthy diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index c56d3bd..254cbfd 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -16,8 +16,11 @@ def initialize(value) # Ok("foo") == Ok("foo") # => true # Ok("foo") == Err("foo") # => false # Ok("foo").object_id != Ok("foo").object_id # => true + # Ok(1) == 1 # => raise Errgonomic::NotComparableError def ==(other) - self.class == other.class && value == other.value + raise Errgonomic::NotComparableError unless other.is_a?(Any) + return false if self.class != other.class + value == other.value end # Indicate that this is some kind of result object. Contrast to From 5a4f8bc00f9c56e3a17ec796aec30b36913fdd76 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 10:51:42 -0500 Subject: [PATCH 11/20] Lenient inner value comparison on Some and Ok --- lib/errgonomic.rb | 11 +++++++++++ lib/errgonomic/option.rb | 25 +++++++++++++++++++++---- lib/errgonomic/result.rb | 11 +++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index e80c6d6..11ed14e 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -47,4 +47,15 @@ def self.with_ambiguous_downstream_errors(&block) ensure @give_me_ambiguous_downstream_errors = original_value end + + # Lenient inner value comparison means the inner value of a Some or Ok can be + # compared to some other non-Result or non-Option value. + def self.lenient_inner_value_comparison? + @lenient_inner_value_comparison ||= true + end + + def self.give_me_lenient_inner_value_comparison=(value) + @lenient_inner_value_comparison = value + end + end diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index e795cda..11b21ec 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -5,17 +5,34 @@ module Option class Any # An option of the same type with an equal inner value is equal. # - # TODO: we're going to monkey patch this into Rails, so we might want to - # allow spooky Some(T) == T comparability. + # Because we're going to monkey patch this into other libraries Rails, we + # allow some "pass through" functionality into the inner value of a Some, + # such as comparability here. + # + # TODO: does None == null? + # + # strict: + # Some(1) == 1 # => raise Errgonomic::NotComparableError, "Cannot compare Errgonomic::Option::Some with Integer" # # @example # Some(1) == Some(1) # => true # Some(1) == Some(2) # => false # Some(1) == None() # => false # None() == None() # => true - # Some(1) == 1 # => raise Errgonomic::NotComparableError, "Cannot compare Errgonomic::Option::Some with Integer" + # Some(1) == 1 # => true def ==(other) - raise NotComparableError, "Cannot compare #{self.class} with #{other.class}" unless other.is_a?(Any) + + unless other.is_a?(Any) + if Errgonomic.lenient_inner_value_comparison? + # allow comparison of other types of values to the inner of a Some + return true if some? && !other.is_a?(Any) && self.value == other + else + # strictly compare to other Options only + raise NotComparableError, "Cannot compare #{self.class} with #{other.class}" + end + end + + # trivial comparisions of an Option to another Option return false if self.class != other.class return true if none? diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 254cbfd..da60b88 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -16,9 +16,16 @@ def initialize(value) # Ok("foo") == Ok("foo") # => true # Ok("foo") == Err("foo") # => false # Ok("foo").object_id != Ok("foo").object_id # => true - # Ok(1) == 1 # => raise Errgonomic::NotComparableError + # Ok(1) == 1 # => true def ==(other) - raise Errgonomic::NotComparableError unless other.is_a?(Any) + unless other.is_a?(Any) + if Errgonomic.lenient_inner_value_comparison? + return true if ok? && value == other + else + raise Errgonomic::NotComparableError, "Cannot compare #{self.class} to #{other.class}" + end + end + # trivial comparison of a Result to another Result return false if self.class != other.class value == other.value end From cfd66fdcac5171cb35304ec5cafefebf5eb5d114 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 10:53:32 -0500 Subject: [PATCH 12/20] Let's allow None to eq nil --- lib/errgonomic/option.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 11b21ec..13749a1 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -20,12 +20,14 @@ class Any # Some(1) == None() # => false # None() == None() # => true # Some(1) == 1 # => true + # None() == nil # => true def ==(other) unless other.is_a?(Any) if Errgonomic.lenient_inner_value_comparison? # allow comparison of other types of values to the inner of a Some return true if some? && !other.is_a?(Any) && self.value == other + return true if none? && other.nil? else # strictly compare to other Options only raise NotComparableError, "Cannot compare #{self.class} with #{other.class}" From fffd90c52b484557abd107d8c69c08d97265aab5 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 21:16:25 -0500 Subject: [PATCH 13/20] Back to a simpler, stricter approach --- lib/errgonomic/option.rb | 20 ++------------------ lib/errgonomic/result.rb | 14 +++----------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 13749a1..e246926 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -1,7 +1,5 @@ module Errgonomic module Option - - class Any # An option of the same type with an equal inner value is equal. # @@ -19,22 +17,9 @@ class Any # Some(1) == Some(2) # => false # Some(1) == None() # => false # None() == None() # => true - # Some(1) == 1 # => true - # None() == nil # => true + # Some(1) == 1 # => false + # None() == nil # => false def ==(other) - - unless other.is_a?(Any) - if Errgonomic.lenient_inner_value_comparison? - # allow comparison of other types of values to the inner of a Some - return true if some? && !other.is_a?(Any) && self.value == other - return true if none? && other.nil? - else - # strictly compare to other Options only - raise NotComparableError, "Cannot compare #{self.class} with #{other.class}" - end - end - - # trivial comparisions of an Option to another Option return false if self.class != other.class return true if none? @@ -255,7 +240,6 @@ def none? end end end - end # Global convenience for constructing a Some value. diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index da60b88..a2a2cfa 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -16,17 +16,11 @@ def initialize(value) # Ok("foo") == Ok("foo") # => true # Ok("foo") == Err("foo") # => false # Ok("foo").object_id != Ok("foo").object_id # => true - # Ok(1) == 1 # => true + # Ok(1) == 1 # => false + # Err() == nil # => false def ==(other) - unless other.is_a?(Any) - if Errgonomic.lenient_inner_value_comparison? - return true if ok? && value == other - else - raise Errgonomic::NotComparableError, "Cannot compare #{self.class} to #{other.class}" - end - end - # trivial comparison of a Result to another Result return false if self.class != other.class + value == other.value end @@ -252,7 +246,6 @@ def ok? false end end - end end @@ -286,7 +279,6 @@ def assert_result! end end - # Global convenience method for constructing an Ok result. def Ok(value) Errgonomic::Result::Ok.new(value) From a1cefda180b40c322eb05921639e3c9fc2a74cc9 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 14 Mar 2025 22:00:55 -0500 Subject: [PATCH 14/20] linting; rewrite some examples; simplify comparability checking --- .rubocop.yml | 1 + Gemfile | 1 + Gemfile.lock | 6 +- bin/console | 6 +- doctest_helper.rb | 4 +- errgonomic.gemspec | 32 +-- gemset.nix | 13 +- lib/errgonomic.rb | 7 +- lib/errgonomic/core_ext/blank.rb | 8 +- lib/errgonomic/option.rb | 2 + lib/errgonomic/presence.rb | 7 +- lib/errgonomic/result.rb | 114 +++++---- lib/errgonomic/version.rb | 2 +- spec/errgonomic_spec.rb | 417 ------------------------------- spec/spec_helper.rb | 15 -- 15 files changed, 126 insertions(+), 509 deletions(-) create mode 100644 .rubocop.yml delete mode 100644 spec/errgonomic_spec.rb delete mode 100644 spec/spec_helper.rb diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..2c01aab --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1 @@ +require: rubocop-yard diff --git a/Gemfile b/Gemfile index 524e664..965df16 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gemspec gem 'rake', '~> 13.0', group: :development gem 'rspec', '~> 3.0', group: :development gem 'rubocop', group: :development +gem 'rubocop-yard', group: :development gem 'solargraph', group: :development # gem "standard", "~> 1.3", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 33f1286..ca280a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.1.0) + errgonomic (0.2.0) concurrent-ruby (~> 1.0) GEM @@ -65,6 +65,9 @@ GEM unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.38.1) parser (>= 3.3.1.0) + rubocop-yard (0.10.0) + rubocop (~> 1.21) + yard ruby-progressbar (1.13.0) solargraph (0.52.0) backport (~> 1.2) @@ -105,6 +108,7 @@ DEPENDENCIES rake (~> 13.0) rspec (~> 3.0) rubocop + rubocop-yard solargraph yard (~> 0.9) yard-doctest (~> 0.1) diff --git a/bin/console b/bin/console index 395721b..062f92b 100755 --- a/bin/console +++ b/bin/console @@ -1,11 +1,11 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require "bundler/setup" -require "errgonomic" +require 'bundler/setup' +require 'errgonomic' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. -require "irb" +require 'irb' IRB.start(__FILE__) diff --git a/doctest_helper.rb b/doctest_helper.rb index 262b7d0..5bc96b3 100644 --- a/doctest_helper.rb +++ b/doctest_helper.rb @@ -1 +1,3 @@ -require_relative "lib/errgonomic" +# frozen_string_literal: true + +require_relative 'lib/errgonomic' diff --git a/errgonomic.gemspec b/errgonomic.gemspec index 55a3eea..22e4b88 100644 --- a/errgonomic.gemspec +++ b/errgonomic.gemspec @@ -1,26 +1,26 @@ # frozen_string_literal: true # when we build in the nix store, version.rb is hashed and adjacent to the gemspec -if __FILE__.include?("/nix/store") && (version_file = Dir.glob("./*-version.rb").first) +if __FILE__.include?('/nix/store') && (version_file = Dir.glob('./*-version.rb').first) require_relative version_file else - require_relative "lib/errgonomic/version" + require_relative 'lib/errgonomic/version' end Gem::Specification.new do |spec| - spec.name = "errgonomic" + spec.name = 'errgonomic' spec.version = Errgonomic::VERSION - spec.authors = ["Nick Zadrozny"] - spec.email = ["nick@onemorecloud.com"] + spec.authors = ['Nick Zadrozny'] + spec.email = ['nick@onemorecloud.com'] - spec.summary = "Opinionated, ergonomic error handling for Ruby, inspired by Rails and Rust." + spec.summary = 'Opinionated, ergonomic error handling for Ruby, inspired by Rails and Rust.' spec.description = "Let's blend the Rails 'present' and 'blank' conventions with a few patterns from Rust Option types." - spec.homepage = "https://omc.io/" - spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" + spec.homepage = 'https://omc.io/' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.0.0' - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "https://github.com/omc/errgonomic" + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/omc/errgonomic' # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -31,16 +31,16 @@ Gem::Specification.new do |spec| f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) end end - spec.bindir = "exe" + spec.bindir = 'exe' spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] + spec.require_paths = ['lib'] # Uncomment to register a new dependency of your gem # spec.add_dependency "example-gem", "~> 1.0" - spec.add_dependency "concurrent-ruby", "~> 1.0" - spec.add_development_dependency "yard", "~> 0.9" - spec.add_development_dependency "yard-doctest", "~> 0.1" + spec.add_dependency 'concurrent-ruby', '~> 1.0' + spec.add_development_dependency 'yard', '~> 0.9' + spec.add_development_dependency 'yard-doctest', '~> 0.1' # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/gemset.nix b/gemset.nix index 7c1e4d3..22b520a 100644 --- a/gemset.nix +++ b/gemset.nix @@ -57,7 +57,7 @@ path = ./.; type = "path"; }; - version = "0.1.0"; + version = "0.2.0"; }; jaro_winkler = { groups = ["default"]; @@ -341,6 +341,17 @@ }; version = "1.38.1"; }; + rubocop-yard = { + dependencies = ["rubocop" "yard"]; + groups = ["development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "03s8lwah6apkr1g25whhd9y2zrqq9dy56g5kwn0bxp0slakrpisz"; + type = "gem"; + }; + version = "0.10.0"; + }; ruby-progressbar = { groups = ["default"]; platforms = []; diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 11ed14e..fc54201 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -unless defined?(Errgonomic::VERSION) - require_relative 'errgonomic/version' -end +require_relative 'errgonomic/version' unless defined?(Errgonomic::VERSION) # A more opinionated blend with Rails presence. require_relative 'errgonomic/presence' @@ -40,7 +38,7 @@ def self.give_me_ambiguous_downstream_errors? # You can opt out of the pedantic runtime checks for lazy block evaluation, # but not quietly. - def self.with_ambiguous_downstream_errors(&block) + def self.with_ambiguous_downstream_errors original_value = @give_me_ambiguous_downstream_errors @give_me_ambiguous_downstream_errors = true yield @@ -57,5 +55,4 @@ def self.lenient_inner_value_comparison? def self.give_me_lenient_inner_value_comparison=(value) @lenient_inner_value_comparison = value end - end diff --git a/lib/errgonomic/core_ext/blank.rb b/lib/errgonomic/core_ext/blank.rb index 84c7e0a..b784d05 100644 --- a/lib/errgonomic/core_ext/blank.rb +++ b/lib/errgonomic/core_ext/blank.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "concurrent/map" +require 'concurrent/map' class Object # An object is blank if it's false, empty, or a whitespace string. @@ -99,7 +99,7 @@ class Array # [1,2,3].blank? # => false # # @return [true, false] - alias_method :blank?, :empty? + alias blank? empty? def present? # :nodoc: !empty? @@ -113,7 +113,7 @@ class Hash # { key: 'value' }.blank? # => false # # @return [true, false] - alias_method :blank?, :empty? + alias blank? empty? def present? # :nodoc: !empty? @@ -125,7 +125,7 @@ class Symbol # # :''.blank? # => true # :symbol.blank? # => false - alias_method :blank?, :empty? + alias blank? empty? def present? # :nodoc: !empty? diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index e246926..ecfddf9 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Errgonomic module Option class Any diff --git a/lib/errgonomic/presence.rb b/lib/errgonomic/presence.rb index 3ac700a..d123261 100644 --- a/lib/errgonomic/presence.rb +++ b/lib/errgonomic/presence.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # The semantics here borrow heavily from ActiveSupport. Let's prefer that if # loaded, otherwise just copypasta the bits we like. Or convince me to make that # gem a dependency. @@ -53,6 +55,7 @@ def present_or_else(&block) # @return [Object] The receiver if it is blank, otherwise raises a NotPresentError. def blank_or_raise(message) raise Errgonomic::NotPresentError, message unless blank? + self end @@ -63,7 +66,8 @@ def blank_or_raise(message) def blank_or(value) # TBD whether this is *too* strict if value.class != self.class && self.class != NilClass - raise Errgonomic::TypeMismatchError, "Type mismatch: default value is a #{value.class} but original was a #{self.class}" + raise Errgonomic::TypeMismatchError, + "Type mismatch: default value is a #{value.class} but original was a #{self.class}" end return self if blank? @@ -78,6 +82,7 @@ def blank_or(value) # @return [Object] The receiver if it is blank, otherwise the result of the block. def blank_or_else(&block) return block.call unless blank? + self end end diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index a2a2cfa..6e28c17 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Errgonomic module Result # The base class for Result's Ok and Err class variants. We implement as @@ -12,10 +14,12 @@ def initialize(value) # Equality comparison for Result objects is based on value not reference. # + # @param other [Object] + # # @example - # Ok("foo") == Ok("foo") # => true - # Ok("foo") == Err("foo") # => false - # Ok("foo").object_id != Ok("foo").object_id # => true + # Ok(1) == Ok(1) # => true + # Ok(1) == Err(1) # => false + # Ok(1).object_id == Ok(1).object_id # => false # Ok(1) == 1 # => false # Err() == nil # => false def ==(other) @@ -26,10 +30,11 @@ def ==(other) # Indicate that this is some kind of result object. Contrast to # Object#result? which is false for all other types. + # # @example - # Ok("foo").result? # => true - # Err("foo").result? # => true - # "foo".result? # => false + # Ok("a").result? # => true + # Err("a").result? # => true + # "a".result? # => false def result? true end @@ -37,11 +42,13 @@ def result? # Return true if the inner value is an Ok and the result of the block is # truthy. # + # @param [Proc] block The block to evaluate if the inner value is an Ok. + # # @example - # Ok("foo").ok_and? { |_| true } # => true - # Ok("foo").ok_and? { |_| false } # => false - # Err("foo").ok_and? { |_| true } # => false - # Err("foo").ok_and? { |_| false } # => false + # Ok(1).ok_and?(&:odd?) # => true + # Ok(1).ok_and?(&:even?) # => false + # Err(:a).ok_and? { |_| true } # => false + # Err(:b).ok_and? { |_| false } # => false def ok_and?(&block) if ok? !!block.call(value) @@ -54,10 +61,10 @@ def ok_and?(&block) # truthy. # # @example - # Ok("foo").err_and? { |_| true } # => false - # Ok("foo").err_and? { |_| false } # => false - # Err("foo").err_and? { |_| true } # => true - # Err("foo").err_and? { |_| false } # => false + # Ok(1).err_and?(&:odd?) # => false + # Ok(1).err_and?(&:even?) # => false + # Err(:a).err_and? { |_| true } # => true + # Err(:b).err_and? { |_| false } # => false def err_and?(&block) if err? !!block.call(value) @@ -69,8 +76,8 @@ def err_and?(&block) # Return the inner value of an Ok, else raise an exception when Err. # # @example - # Ok("foo").unwrap! # => "foo" - # Err("foo").unwrap! # => raise Errgonomic::UnwrapError, "value is an Err" + # Ok(1).unwrap! # => 1 + # Err(:c).unwrap! # => raise Errgonomic::UnwrapError, "value is an Err" def unwrap! raise Errgonomic::UnwrapError, 'value is an Err' unless ok? @@ -80,9 +87,11 @@ def unwrap! # Return the inner value of an Ok, else raise an exception with the given # message when Err. # + # @param msg [String] + # # @example - # Ok("foo").expect!("foo") # => "foo" - # Err("foo").expect!("foo") # => raise Errgonomic::ExpectError, "foo" + # 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? @@ -92,10 +101,10 @@ def expect!(msg) # Return the inner value of an Err, else raise an exception when Ok. # # @example - # Ok("foo").unwrap_err! # => raise Errgonomic::UnwrapError, "value is an Ok" - # Err("foo").unwrap_err! # => "foo" + # Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, 1 + # Err(:e).unwrap_err! # => :e def unwrap_err! - raise Errgonomic::UnwrapError, 'value is an Ok' unless err? + raise Errgonomic::UnwrapError, value unless err? @value end @@ -103,12 +112,14 @@ def unwrap_err! # Given another result, return it if the inner result is Ok, else return # the inner Err. Raise an exception if the other value is not a Result. # + # @param other [Errgonomic::Result::Any] + # # @example - # Ok("foo").and(Ok("bar")) # => Ok("bar") - # Ok("foo").and(Err("bar")) # => Err("bar") - # Err("foo").and(Ok("bar")) # => Err("foo") - # Err("foo").and(Err("bar")) # => Err("foo") - # Ok("foo").and("bar") # => raise Errgonomic::ArgumentError, "other must be a Result" + # Ok(1).and(Ok(2)) # => Ok(2) + # Ok(1).and(Err(:f)) # => Err(:f) + # Err(:g).and(Ok(1)) # => Err(:g) + # Err(:h).and(Err(:i)) # => Err(:h) + # Ok(1).and(2) # => raise Errgonomic::ArgumentError, "other must be a Result" def and(other) raise Errgonomic::ArgumentError, 'other must be a Result' unless other.is_a?(Errgonomic::Result::Any) return self if err? @@ -121,11 +132,18 @@ def and(other) # pedantically check the type of the block's return value at runtime. This # is annoying, sorry, but better than an "undefined method" error. # Hopefully it gives your test suite a chance to detect incorrect usage. + # + # @param block [Proc] + # + # @example + # Ok(1).and_then { |x| Ok(x + 1) } # => Ok(2) + # Ok(1).and_then { |_| Err(:error) } # => Err(:error) + # Err(:error).and_then { |x| Ok(x + 1) } # => Err(:error) + # Err(:error).and_then { |x| Err(:error2) } # => Err(:error) def and_then(&block) - # raise NotImplementedError, "and_then is not implemented yet" return self if err? - res = block.call(self) + res = block.call(value) unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? raise Errgonomic::ArgumentError, 'and_then block must return a Result' end @@ -136,10 +154,12 @@ def and_then(&block) # Return other if self is Err, else return the original Option. Raises a # pedantic runtime exception if other is not a Result. # + # @param other [Errgonomic::Result::Any] + # # @example - # Err("foo").or(Ok("bar")) # => Ok("bar") - # Err("foo").or(Err("baz")) # => Err("baz") - # Err("foo").or("bar") # => raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" + # Err(:j).or(Ok(1)) # => Ok(1) + # Err(:k).or(Err(:l)) # => Err(:l) + # Err(:m).or("oops") # => raise Errgonomic::ArgumentError, "other must be a Result; you might want unwrap_or" def or(other) unless other.is_a?(Errgonomic::Result::Any) raise Errgonomic::ArgumentError, @@ -155,11 +175,13 @@ def or(other) # Sorry about that, hopefully it helps your tests. Better than ambiguous # downstream "undefined method" errors, probably. # + # @param block [Proc] + # # @example - # Ok("foo").or_else { Ok("bar") } # => Ok("foo") - # Err("foo").or_else { Ok("bar") } # => Ok("bar") - # Err("foo").or_else { Err("baz") } # => Err("baz") - # Err("foo").or_else { "bar" } # => raise Errgonomic::ArgumentError, "or_else block must return a Result" + # Ok(1).or_else { Ok(2) } # => Ok(1) + # Err(:o).or_else { Ok(1) } # => Ok(1) + # Err(:q).or_else { Err(:r) } # => Err(:r) + # Err(:s).or_else { "oops" } # => raise Errgonomic::ArgumentError, "or_else block must return a Result" def or_else(&block) return self if ok? @@ -173,9 +195,11 @@ def or_else(&block) # Return the inner value if self is Ok, else return the provided default. # + # @param other [Object] + # # @example - # Ok("foo").unwrap_or("bar") # => "foo" - # Err("foo").unwrap_or("bar") # => "bar" + # Ok(1).unwrap_or(2) # => 1 + # Err(:t).unwrap_or(:u) # => :u def unwrap_or(other) return value if ok? @@ -185,9 +209,11 @@ def unwrap_or(other) # Return the inner value if self is Ok, else lazy evaluate the block and # return its result. # + # @param block [Proc] + # # @example - # Ok("foo").unwrap_or_else { "bar" } # => "foo" - # Err("foo").unwrap_or_else { "bar" } # => "bar" + # Ok(1).unwrap_or_else { 2 } # => 1 + # Err(:v).unwrap_or_else { :w } # => :w def unwrap_or_else(&block) return value if ok? @@ -202,7 +228,7 @@ class Ok < Any # Ok is always ok # # @example - # Ok("foo").ok? # => true + # Ok(1).ok? # => true def ok? true end @@ -210,7 +236,7 @@ def ok? # Ok is never err # # @example - # Ok("foo").err? # => false + # Ok(1).err? # => false def err? false end @@ -224,7 +250,7 @@ class Arbitrary; end # Err may be constructed without a value, if you want. # # @example - # Err("foo").value # => "foo" + # Err(:y).value # => :y # Err().value # => Arbitrary def initialize(value = Arbitrary) super(value) @@ -233,7 +259,7 @@ def initialize(value = Arbitrary) # Err is always err # # @example - # Err("foo").err? # => true + # Err(:z).err? # => true def err? true end @@ -241,7 +267,7 @@ def err? # Err is never ok # # @example - # Err("foo").ok? # => false + # Err(:A).ok? # => false def ok? false end diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index 2d3ef3a..2c8e883 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = "0.2.0" + VERSION = '0.2.0' end diff --git a/spec/errgonomic_spec.rb b/spec/errgonomic_spec.rb deleted file mode 100644 index ddde350..0000000 --- a/spec/errgonomic_spec.rb +++ /dev/null @@ -1,417 +0,0 @@ -# frozen_string_literal: true - -Ok = Errgonomic::Result::Ok -Err = Errgonomic::Result::Err - -Some = Errgonomic::Option::Some -None = Errgonomic::Option::None - -RSpec.describe Errgonomic do - it 'has a version number' do - expect(Errgonomic::VERSION).not_to be nil - end - - describe 'present_or_raise' do - it 'raises an error for various blank objects' do - expect { nil.present_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) - expect { [].present_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) - expect { {}.present_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) - end - - it 'returns the value itself for present types' do - expect('bar'.present_or_raise('foo')).to eq('bar') - expect(['baz'].present_or_raise('foo')).to eq(['baz']) - expect({ foo: 'bar' }.present_or_raise('foo')).to eq({ foo: 'bar' }) - end - end - - describe 'present_or' do - it 'returns the default value for various blank objects' do - expect(nil.present_or('foo')).to eq('foo') - expect([].present_or(['foo'])).to eq(['foo']) - expect({}.present_or({ foo: 'bar' })).to eq({ foo: 'bar' }) - end - - it 'rather strictly requires the value to match the starting type, except for nil' do - expect(nil.present_or('foo')).to eq('foo') - expect { [].present_or('bar') }.to raise_error(Errgonomic::TypeMismatchError) - expect { {}.present_or('bar') }.to raise_error(Errgonomic::TypeMismatchError) - end - - it 'even more strictly will fail when default value is not the same type as the original non-blank value' do - expect { ['foo'].present_or('bad') }.to raise_error(Errgonomic::TypeMismatchError) - expect { { foo: 'bar' }.present_or('bad') }.to raise_error(Errgonomic::TypeMismatchError) - end - - it 'returns the value itself for present types' do - expect('bar'.present_or('foo')).to eq('bar') - expect(['baz'].present_or(['foo'])).to eq(['baz']) - expect({ foo: 'bar' }.present_or({ foo: 'baz' })).to eq({ foo: 'bar' }) - end - end - - describe 'blank_or_raise' do - it 'raises an error for present objects' do - expect { 'bar'.blank_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) - expect { ['baz'].blank_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) - expect { { foo: 'bar' }.blank_or_raise('foo') }.to raise_error(Errgonomic::NotPresentError) - end - - it 'returns the value itself for blank types' do - expect(nil.blank_or_raise('foo')).to eq(nil) - expect([].blank_or_raise('foo')).to eq([]) - expect({}.blank_or_raise('foo')).to eq({}) - end - end - - describe 'blank_or' do - it 'returns the receiver for blank objects' do - expect(nil.blank_or('foo')).to eq(nil) - expect([].blank_or(['foo'])).to eq([]) - expect({}.blank_or({ foo: 'bar' })).to eq({}) - end - - it 'returns the default value for present objects' do - expect('bar'.blank_or('foo')).to eq('foo') - expect(['baz'].blank_or(['foo'])).to eq(['foo']) - expect({ foo: 'bar' }.blank_or({ foo: 'baz' })).to eq({ foo: 'baz' }) - end - - it 'enforces type checks similar to present_or' do - expect { 'bar'.blank_or(['foo']) }.to raise_error(Errgonomic::TypeMismatchError) - expect { [].blank_or('foo') }.to raise_error(Errgonomic::TypeMismatchError) - end - end - - describe 'blank_or_else' do - it 'returns the receiver for blank objects' do - expect(nil.blank_or_else { 'foo' }).to eq(nil) - expect([].blank_or_else { ['foo'] }).to eq([]) - expect({}.blank_or_else { { foo: 'bar' } }).to eq({}) - end - - it 'returns the result of the block for present objects' do - expect('bar'.blank_or_else { 'foo' }).to eq('foo') - expect(['baz'].blank_or_else { ['foo'] }).to eq(['foo']) - expect({ foo: 'bar' }.blank_or_else { { foo: 'baz' } }).to eq({ foo: 'baz' }) - end - end - - describe 'Result' do - describe 'Ok' do - it 'must be constructed with an inner value' do - expect { Ok.new }.to raise_error(ArgumentError) - end - end - - describe 'Err' do - it 'can be constructed with or without an inner value' do - expect(Err.new).to be_err - expect(Err.new('foo')).to be_err - end - - it 'is err' do - result = Errgonomic::Result::Err.new - expect(result).to be_err - end - - it 'is not ok' do - result = Errgonomic::Result::Err.new - expect(result).not_to be_ok - end - - it 'raises exception on unwrap' do - result = Errgonomic::Result::Err.new('foo') - expect { result.unwrap! }.to raise_error(Errgonomic::UnwrapError) - end - - it 'raises an exception with a given message on expect' do - result = Errgonomic::Result::Err.new('foo') - expect { result.expect!('bar') }.to raise_error(Errgonomic::ExpectError) - end - end - - it 'has a basic dsl for match' do - result = Errgonomic::Result::Err.new('foo') - matched = result.match do - ok { |val| :foo } - err { |err| :bar } - end - expect(matched).to eq(:bar) - - result = Errgonomic::Result::Ok.new('foo') - matched = result.match do - ok { |val| :foo } - err { |err| :bar } - end - expect(matched).to eq(:foo) - end - - describe 'ok_and' do - it 'returns true if ok and the inner block evals to truthy, else false' do - expect(Ok.new('foo').ok_and? { true }).to be true - expect(Ok.new('foo').ok_and? { false }).to be false - expect(Err.new('foo').ok_and? { true }).to be false - expect(Err.new('foo').ok_and? { false }).to be false - end - end - - describe 'err_and?' do - it 'returns true if err and the inner block evals to truthy, else false' do - expect(Err.new('foo').err_and? { true }).to be true - expect(Err.new('foo').err_and? { false }).to be false - expect(Ok.new('foo').err_and? { true }).to be false - expect(Ok.new('foo').err_and? { false }).to be false - end - end - - describe 'and' do - it 'returns the result of the block if the original value is ok, else returns the err value' do - result = Ok.new('foo') - expect(result.and(Ok.new(:bar)).unwrap!).to eq(:bar) - - result = Err.new('foo') - expect(result.and(Ok.new(:bar))).to be_err - end - - it 'must take a block that returns a result; ew' do - result = Ok.new('foo') - expect { result.and(:bar) }.to raise_error(Errgonomic::ArgumentError) - end - end - - describe 'and_then' do - it 'returns the result from the block if the original is an ok' do - result = Ok.new('foo') - expect(result.and_then { Ok.new(:bar) }.unwrap!).to eq(:bar) - result = Err.new('foo') - expect(result.and_then { Ok.new(:bar) }).to eq(result) - end - - it 'is lazy' do - inner = Err.new('foo') - result = inner.and_then { raise 'noisy' } - expect(result).to be_err - expect(result).to eq(inner) - end - - it 'enforces the return type of the block at runtime, ew' do - inner = Ok.new('foo') - expect { inner.and_then { :bar } }.to raise_error(Errgonomic::ArgumentError) - end - - it 'can skip that runtime enforcement, which is so much worse' do - inner = Ok.new('foo') - Errgonomic.with_ambiguous_downstream_errors do - expect { inner.and_then { :bar } }.not_to raise_error - expect(inner.and_then { :baz }).to eq(:baz) - end - end - end - - describe 'or' do - it 'returns the original result when it is Ok' do - expect(Ok.new(:foo).or(Ok.new(:bar)).unwrap!).to eq(:foo) - end - - it 'returns the other result when the original is Err' do - expect(Err.new('foo').or(Ok.new(:bar)).unwrap!).to eq(:bar) - end - - it 'enforces that the other value is a result' do - expect { Err.new('foo').or(:bar) }.to raise_error(Errgonomic::ArgumentError) - end - - it 'cannot opt out of runtime enforcement' do - Errgonomic.with_ambiguous_downstream_errors do - expect { Err.new('foo').or(:bar) }.to raise_error(Errgonomic::ArgumentError) - end - end - end - - describe 'or_else' do - it 'returns the original result when it is Ok' do - expect(Ok.new(:foo).or_else { Ok.new(:bar) }.unwrap!).to eq(:foo) - end - - it 'returns the other result when the original is Err' do - expect(Err.new('foo').or_else { Ok.new(:bar) }.unwrap!).to eq(:bar) - end - - it 'enforces that the other value is a result' do - expect { Err.new('foo').or_else { :bar } }.to raise_error(Errgonomic::ArgumentError) - end - - it 'can opt out of runtime result type enforcement' do - Errgonomic.with_ambiguous_downstream_errors do - expect { Err.new('foo').or_else { :bar } }.not_to raise_error - end - end - end - - describe 'unwrap_or' do - it 'returns the contained Ok value or the provided default' do - expect(Ok.new(:foo).unwrap_or(:bar)).to eq(:foo) - expect(Err.new(:foo).unwrap_or(:bar)).to eq(:bar) - end - end - - describe 'unwrap_or_else' do - it 'returns the contained Ok value or the result of the provided block' do - expect(Ok.new(:foo).unwrap_or_else { raise 'noisy' }).to eq(:foo) - expect(Err.new(:foo).unwrap_or_else { :bar }).to eq(:bar) - end - end - - describe 'Ok()' do - it 'creates an Ok' do - expect(Ok(:foo)).to be_a(Errgonomic::Result::Ok) - end - end - - describe 'Err()' do - it 'creates an Err' do - expect(Err(:foo)).to be_a(Errgonomic::Result::Err) - end - end - - describe 'Object#assert_result!' do - it 'raises an exception if the object is not a Result' do - expect { :foo.assert_result! }.to raise_error(Errgonomic::ResultRequiredError) - expect { Ok(:foo).assert_result! }.not_to raise_error - expect { Err(:foo).assert_result! }.not_to raise_error - end - end - end - - describe 'Option' do - describe 'some' do - it 'can be created with Some()' do - expect(Some(:foo)).to be_a(Errgonomic::Option::Some) - end - it 'is some' do - expect(Some(:foo)).to be_some - end - it 'is not none' do - expect(Some(:foo)).not_to be_none - end - end - - describe 'None' do - it 'can be created with None()' do - expect(None()).to be_a(Errgonomic::Option::None) - end - it 'is none' do - expect(None()).to be_none - end - it 'is not some' do - expect(None()).not_to be_some - end - end - - describe 'some_and' do - it 'returns true if the option is Some and the block is truthy' do - expect(Some(:foo).some_and { true }).to be true - expect(Some(:foo).some_and { false }).to be false - end - it 'returns false if the option is None' do - expect(None().some_and { true }).to be false - expect(None().some_and { false }).to be false - end - end - - describe 'none_or' do - it 'returns true if the option is None' do - expect(None().none_or { true }).to be true - expect(None().none_or { false }).to be true - end - it 'returns true if the block is truthy' do - expect(Some(:foo).none_or { true }).to be true - expect(Some(:foo).none_or { false }).to be false - end - end - - describe 'to_a' do - it 'returns an array of the contained value, if any' do - expect(Some(:foo).to_a).to eq([:foo]) - expect(None().to_a).to eq([]) - end - end - - describe 'expect!' do - it 'returns the inner value or else raises an error with the given message' do - expect(Some(:foo).expect!('msg')).to eq(:foo) - expect { None().expect!('msg') }.to raise_error(Errgonomic::ExpectError, 'msg') - end - end - - describe 'unwrap!' do - it 'returns the inner value or else raises an error' do - expect(Some(:foo).unwrap!).to eq(:foo) - expect { None().unwrap! }.to raise_error(Errgonomic::UnwrapError) - end - end - - describe 'unwrap_or' do - it 'returns the inner value if present, or the provided value' do - expect(Some(:foo).unwrap_or(:bar)).to eq(:foo) - expect(None().unwrap_or(:bar)).to eq(:bar) - end - end - - describe 'unwrap_or_else' do - it 'returns the inner value if present, or the result of the provided block' do - expect(Some(:foo).unwrap_or_else { :bar }).to eq(:foo) - expect(None().unwrap_or_else { :bar }).to eq(:bar) - end - end - - describe 'tap_some' do - it 'calls the block with the inner value, if some, returning the original Option' do - option = Some(:foo) - tapped = false - expect(option.tap_some { |v| tapped = true }).to eq(option) - expect(tapped).to be true - option = None() - tapped = false - expect(option.tap_some { |v| tapped = true }).to eq(option) - expect(tapped).to be false - end - end - - describe 'map' - - describe 'map_or' do - it 'returns the provided result (if None) or applies a function to the contained value (if Some)' do - option = Some(0) - val = option.map_or(100) { |v| v + 1 } - expect(val).to eq(1) - end - end - - describe 'map_or_else' - - describe 'ok_or' - describe 'ok_or_else' - - describe 'and' - describe 'and_then' - - describe 'filter' - - describe 'or' - describe 'or_else' - describe 'xor' - - describe 'insert' - describe 'get_or_insert' - describe 'get_or_insert_with' - - describe 'take' - describe 'take_if' - describe 'replace' - - describe 'zip' - describe 'zip_with' - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 1eaae8f..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require "errgonomic" - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` - config.disable_monkey_patching! - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end From cbc6ebb5a9b90da7f11ec5d411d472e327c69bcd Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Mon, 17 Mar 2025 13:35:32 -0500 Subject: [PATCH 15/20] Option#or, #or_else, #and, #and_then. Default back to strict type enforcment. --- lib/errgonomic.rb | 2 +- lib/errgonomic/option.rb | 66 ++++++++++++++++++++++++++++++++++++---- lib/errgonomic/result.rb | 4 +-- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index fc54201..348e7df 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -33,7 +33,7 @@ class NotComparableError < StandardError; end # A little bit of control over how pedantic we are in our runtime type checks. def self.give_me_ambiguous_downstream_errors? - @give_me_ambiguous_downstream_errors ||= false + @give_me_ambiguous_downstream_errors || true end # You can opt out of the pedantic runtime checks for lazy block evaluation, diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index ecfddf9..7c6db78 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -2,6 +2,7 @@ module Errgonomic module Option + # The base class for all options. Some and None are subclasses. class Any # An option of the same type with an equal inner value is equal. # @@ -133,7 +134,7 @@ def map(&block) return self if none? res = block.call(value) - unless res.is_a?(Errgonomic::Option::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + if !res.is_a?(Errgonomic::Option::Any) && Errgonomic.give_me_ambiguous_downstream_errors? raise ArgumentError, 'block must return an Option' end @@ -199,12 +200,65 @@ def ok_or_else(&block) Errgonomic::Result::Err.new(block.call) end - # TODO: - # and - # and_then + # Returns the option if it contains a value, otherwise returns the provided Option. Returns an Option. + # + # @example + # None().or(Some(1)) # => Some(1) + # Some(2).or(Some(3)) # => Some(2) + # None().or(2) # => raise Errgonomic::ArgumentError.new, "other must be an Option, was Integer" + def or(other) + raise ArgumentError, "other must be an Option, was #{other.class.name}" unless other.is_a?(Any) + + return self if some? + + other + end + + # Returns the option if it contains a value, otherwise calls the block and returns the result. Returns an Option. + # + # @example + # None().or_else { Some(1) } # => Some(1) + # Some(2).or_else { Some(3) } # => Some(2) + # None().or_else { 2 } # => raise Errgonomic::ArgumentError.new, "block must return an Option, was Integer" + def or_else(&block) + return self if some? + + val = block.call + if !val.is_a?(Errgonomic::Option::Any) && Errgonomic.give_me_ambiguous_downstream_errors? + raise Errgonomic::ArgumentError.new, "block must return an Option, was #{val.class.name}" + end + + val + end + + # If self is Some, return the provided other Option. + # + # @example + # None().and(Some(1)) # => None() + # Some(2).and(Some(3)) # => Some(3) + def and(other) + return self if none? + + other + end + + # If self is Some, call the given block and return its value. Block most return an Option. + # + # @example + # None().and_then { Some(1) } # => None() + # Some(2).and_then { Some(3) } # => Some(3) + def and_then(&block) + return self if none? + + val = block.call + if Errgonomic.give_me_ambiguous_downstream_errors? && !val.is_a?(Errgonomic::Option::Any) + raise Errgonomic::ArgumentError.new, "block must return an Option, was #{val.class.name}" + end + + val + end + # filter - # or - # or_else # xor # insert # get_or_insert diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 6e28c17..2aa758c 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -144,7 +144,7 @@ def and_then(&block) return self if err? res = block.call(value) - unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + if !res.is_a?(Errgonomic::Result::Any) && Errgonomic.give_me_ambiguous_downstream_errors? raise Errgonomic::ArgumentError, 'and_then block must return a Result' end @@ -186,7 +186,7 @@ def or_else(&block) return self if ok? res = block.call(self) - unless res.is_a?(Errgonomic::Result::Any) || Errgonomic.give_me_ambiguous_downstream_errors? + if !res.is_a?(Errgonomic::Result::Any) && Errgonomic.give_me_ambiguous_downstream_errors? raise Errgonomic::ArgumentError, 'or_else block must return a Result' end From 2b1904b642c2ee4a5f6e384cc7cfcf734514f85f Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Mon, 17 Mar 2025 14:14:00 -0500 Subject: [PATCH 16/20] test with yard doctest --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e9fe282..f014865 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,5 +23,5 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Run the default task - run: bundle exec rake spec + - name: Run yard doctest + run: bundle exec yard doctest From 5431122838b6a60d70d57d736400a6a97bdd0310 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 18 Mar 2025 10:50:38 -0500 Subject: [PATCH 17/20] I think my map semantics were wrong --- lib/errgonomic/option.rb | 41 +++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 7c6db78..15c1d6c 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -41,6 +41,8 @@ def some_and(&block) !!block.call(value) end + alias some_and? some_and + # return true if the contained value is None or the block returns truthy # # @example @@ -53,6 +55,8 @@ def none_or(&block) !!block.call(value) end + alias none_or? none_or + # return an Array with the contained value, if any # @example # Some(1).to_a # => [1] @@ -125,20 +129,15 @@ def tap_some(&block) # 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. + # # @example - # Some(1).map { |x| Some(x + 1) } # => Some(2) - # Some(1).map { |x| None() } # => None() - # None().map { Some(1) } # => None() - # Some(1).map { :foo } # => raise Errgonomic::ArgumentError, "block must return an Option" + # Some(1).map { |x| x + 1 } # => Some(2) + # Some(1).map { |x| None() } # => Some(None()) + # None().map { 1 } # => None() def map(&block) return self if none? - res = block.call(value) - if !res.is_a?(Errgonomic::Option::Any) && Errgonomic.give_me_ambiguous_downstream_errors? - raise ArgumentError, 'block must return an Option' - end - - res + Some(block.call(value)) end # Returns the provided default (if none), or applies a function to the @@ -146,25 +145,29 @@ def map(&block) # value, use +map_or_else+. # # @example - # None().map_or(1) { 100 } # => 1 - # Some(1).map_or(1) { |x| x + 1 } # => 2 - # Some("foo").map_or(0) { |str| str.length } # => 3 + # 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) def map_or(default, &block) - return default if none? + return Some(default) if none? - block.call(value) + Some(block.call(value)) end # Computes a default from the given Proc if None, or applies the block to # the contained value (if Some). # # @example - # None().map_or_else(-> { :foo }) { :bar } # => :foo - # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3 + # 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() def map_or_else(proc, &block) - return proc.call if none? + if none? + val = proc.call + return val ? Some(val) : None() + end - block.call(value) + Some(block.call(value)) end # convert the option into a result where Some is Ok and None is Err From 49e12d65991d5a7819f521fb1449fc22e9626318 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 18 Mar 2025 11:23:49 -0500 Subject: [PATCH 18/20] My map semantics was wrong --- lib/errgonomic/option.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 15c1d6c..e133115 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -132,8 +132,7 @@ def tap_some(&block) # # @example # Some(1).map { |x| x + 1 } # => Some(2) - # Some(1).map { |x| None() } # => Some(None()) - # None().map { 1 } # => None() + # None().map { |x| x + 1 } # => None() def map(&block) return self if none? @@ -273,6 +272,7 @@ def and_then(&block) # zip_with end + # Represent a value class Some < Any attr_accessor :value From 9df77c622493ac9194f2ccf4a6d8aea3f1f6e4e5 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Tue, 18 Mar 2025 13:04:52 -0500 Subject: [PATCH 19/20] pattern match with deconstruct --- lib/errgonomic/option.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index e133115..dc48de8 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -3,6 +3,7 @@ module Errgonomic module Option # The base class for all options. Some and None are subclasses. + # class Any # An option of the same type with an equal inner value is equal. # @@ -29,6 +30,22 @@ def ==(other) value == other.value end + # @example + # measurement = Errgonomic::Option::Some.new(1) + # case measurement + # in Errgonomic::Option::Some, value + # "Measurement is #{measurement.value}" + # in Errgonomic::Option::None + # "Measurement is not available" + # else + # "not matched" + # end # => "Measurement is 1" + def deconstruct + return [self, value] if some? + + [Errgonomic::Option::None] + end + # return true if the contained value is Some and the block returns truthy # # @example From c1ac24af89c397313b9bf892ff77981bcbd39d67 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 19 Mar 2025 08:47:38 -0500 Subject: [PATCH 20/20] Option#zip and Option#zip_with --- lib/errgonomic/option.rb | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index dc48de8..15f0d7f 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -277,6 +277,35 @@ def and_then(&block) val end + # Zips self with another Option. + # + # If self is Some(s) and other is Some(o), this method returns + # Some([s, o]). Otherwise, None is returned. + # + # @example + # None().zip(Some(1)) # => None() + # Some(1).zip(None()) # => None() + # Some(2).zip(Some(3)) # => Some([2, 3]) + def zip(other) + return None() unless some? && other.some? + + Some([value, other.value]) + end + + # Zip two options using the block passed. If self is Some and Other is + # some, yield both of their values to the block and return its value as + # Some. Else return None. + # + # @example + # None().zip_with(Some(1)) { |a, b| a + b } # => None() + # Some(1).zip_with(None()) { |a, b| a + b } # => None() + # Some(2).zip_with(Some(3)) { |a, b| a + b } # => Some(5) + def zip_with(other, &block) + return None() unless some? && other.some? + other = block.call(value, other.value) + Some(other) + end + # filter # xor # insert