├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── docs ├── README.template.md ├── basics.rb ├── data │ ├── users.json │ └── wrong_format.json ├── get_or_else.rb └── setup.rb ├── lib ├── ytry.rb └── ytry │ └── version.rb ├── test ├── scala_try_test.rb ├── test_helper.rb └── ytry_test.rb └── ytry.gemspec /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /vendor 11 | /ytry-*.*.*.gem 12 | /_site/ 13 | /test/coverage/ 14 | /docs/coverage/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.2.0 5 | before_install: gem install bundler -v 1.10.6 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'pry', '~> 0.10.3' 4 | gem 'rb-readline', '~> 0.5.3' 5 | # Specify your gem's dependencies in ytry.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 lorenzo.barasti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/ytry.svg)](https://badge.fury.io/rb/ytry) 2 | [![Build Status](https://travis-ci.org/lbarasti/ytry.svg?branch=master)](https://travis-ci.org/lbarasti/ytry) [![Coverage Status](https://coveralls.io/repos/github/lbarasti/ytry/badge.svg?branch=master)](https://coveralls.io/github/lbarasti/ytry?branch=master) 3 | 4 | # Ytry 5 | 6 | A [Scala](http://www.scala-lang.org/api/current/index.html#scala.util.Try) inspired gem that introduces `Try`s to Ruby while aiming for an idiomatic API. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'ytry' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install ytry 23 | 24 | ## Basic usage 25 | 26 | The Try type represents a computation that may either result in an error, or return a successfully computed value. 27 | 28 | If the block passed to Try runs with no errors, then a `Success` wrapping the computed value is returned. 29 | 30 | An instance of `Failure` wrapping the error is returned otherwise. 31 | 32 | ```ruby 33 | require 'ytry' 34 | include Ytry 35 | 36 | Try { 1 + 1 } # Success(2) 37 | 38 | Try { 1 / 0 } # Failure(#) 39 | ``` 40 | 41 | `Success` and `Failure` provide a unified API that lets us express a sequence of tranformations in a fluent way, without error handling cluttering the flow: 42 | 43 | ```ruby 44 | def load_and_parse json_file 45 | Try { File.read(json_file) } 46 | .map {|content| JSON.parse(content)} 47 | .select {|table| table.is_a? Array} 48 | .recover {|e| puts "Recovering from #{e.message}"; []} 49 | end 50 | 51 | load_and_parse(nonexisting_file) # prints "Recovering from No such file..." # Success([]) 52 | 53 | load_and_parse(wrong_format_file) # prints "Recovering from Element not found" # Success([]) 54 | 55 | load_and_parse(actual_file) # Success([{"id"=>1, "name"=>"Lorenzo", "dob"=>"22/07/1985"}]) 56 | ``` 57 | 58 | `Try#map` and `Try#recover` are means to interact with the value wrapped by a Try in a safe way - i.e. with no risk of errors being raised. 59 | 60 | `Try#select` transforms a Success into a Failure when the underlying value does not satisfy the given predicate - i.e. the given block returns false. That can be useful when validating some input. 61 | 62 | `Try#get_or_else` provides a safe way of retrieving the possibly-missing value it contains. It returns the result of the given block when the Try is a Failure. It is equivalent to `Try#get` when the Try is a Success. 63 | 64 | ```ruby 65 | invalid_json = "[\"missing_quote]" 66 | 67 | Try { JSON.parse(invalid_json) } 68 | .get_or_else{ [] } # [] 69 | 70 | Try { JSON.parse("[]") } 71 | .get_or_else { fail "this block is ignored"} # [] 72 | ``` 73 | 74 | It is preferable to use `Try#get_or_else` over `Try#get`, as `#get` will raise an error when called on a Failure. It is possible to check for failure via `#empty?`, but that tipically leads to non-idiomatic code 75 | 76 | ## Why Try? 77 | 78 | Using Try instead of rescue blocks can make your software both clearer and safer as it 79 | 80 | - leads to less verbose error handling 81 | - simplifies the way we deal with operations that might fail for several reasons (such as IO operations) 82 | - privileges method chaining thus reducing the need for auxiliary variables to store intermediate results in a computation 83 | - encourages programming towards immutability, where the data is transformed rather than mutated in place. 84 | 85 | ## Advanced Usage 86 | ### #reduce 87 | Given a Try instance `try`, a value `c` and a lambda `f`, 88 | ``` 89 | try.reduce(c, &f) 90 | ``` 91 | returns `f.(c, try)` if `try` is a `Success` AND the evaluation of the lambda `f` did not throw any error, it returns `c` otherwise. 92 | 93 | This is a shortcut to 94 | ``` 95 | try.map{|v| f.(c,v)}.get_or_else {c} 96 | ``` 97 | 98 | 99 | ### #flatten 100 | When dealing with nested `Try`s we can use flatten to reduce the level of nesting 101 | ``` 102 | success = Try{:ok} 103 | failure = Try{fail} 104 | Try{success}.flatten # Success(:ok) 105 | Try{failure}.flatten # Failure(RuntimeError) 106 | ``` 107 | flatten accepts one argument, defaulting to 1, which indicates the depth of the flattening operation. Mind that a `Success` can only be flattened as long as it wraps a Try instance. A `Failure` can be flattened an arbitrary number of times, as it always returns itself. 108 | ``` 109 | Try{success}.flatten(1) == Try{success}.flatten # true 110 | Try{success}.flatten(2) # raises TypeError: Argument must be an array-like object. Found Fixnum 111 | ``` 112 | 113 | ### Interoperability with Array-like obects 114 | Because of it's ary-like nature, instances of `Try` play well with Array instances. In particular, flattening an Array of `Try`s is equivalent to filtering out the `Failures` from the array and then calling #get on the Success instances 115 | ``` 116 | (1..4).map{|v| Try{v}.select(&:odd?)} 117 | .flatten # [1, 3] 118 | ``` 119 | Behind the scenes `Array#flatten` is iterating over the collection and concatenating the ary-representation of each element. 120 | Now `Failure#to_ary` returns `[]`, while `Success#to_ary` returns `[v]` - where `v` is the value wrapped by Success - and that does the trick. 121 | 122 | We can squeeze the code listed above even more with `Array#flat_map` 123 | ``` 124 | (1..4).flat_map{|v| Try{v}.select(&:odd?)} # [1, 3] 125 | ``` 126 | Again, there is no magic behind this behaviour, we are just exploiting Ruby's duck typing. 127 | 128 | ## Development 129 | 130 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 131 | 132 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 133 | 134 | 135 | ## Contributing 136 | 137 | Bug reports and pull requests are welcome on GitHub at https://github.com/lbarasti/ytry. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 138 | 139 | 140 | ## License 141 | 142 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 143 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/**/*_test.rb'] 8 | end 9 | 10 | task :docs do 11 | partial_keyword = '<<<<<' 12 | ignore_keyword = "# IGNORE\n" 13 | comment_keyword = "# COMMENT\n" 14 | src = './docs/README.template.md' 15 | target = './README.md' 16 | content = File.readlines(src).flat_map {|line| 17 | if line.lstrip.start_with?(partial_keyword) 18 | partial_file = line.lstrip[partial_keyword.size...-1] 19 | sh 'ruby', partial_file 20 | File.readlines partial_file 21 | else 22 | line 23 | end 24 | } 25 | File.open(target, 'w') {|f| 26 | content.reject{|line| 27 | line.end_with?(ignore_keyword) 28 | }.each_cons(2){|line1,line2| 29 | next if line1.end_with?(comment_keyword) 30 | if line2.end_with?(comment_keyword) 31 | f.puts "#{line1.chomp} # #{line2.match(/'(.*)'/)[1]}\n" 32 | else 33 | f.puts line1 34 | end 35 | } 36 | } 37 | end 38 | 39 | task :default => :test 40 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require 'ytry' 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "pry" 10 | Pry.start 11 | 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /docs/README.template.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/ytry.svg)](https://badge.fury.io/rb/ytry) 2 | [![Build Status](https://travis-ci.org/lbarasti/ytry.svg?branch=master)](https://travis-ci.org/lbarasti/ytry) [![Coverage Status](https://coveralls.io/repos/github/lbarasti/ytry/badge.svg?branch=master)](https://coveralls.io/github/lbarasti/ytry?branch=master) 3 | 4 | # Ytry 5 | 6 | A [Scala](http://www.scala-lang.org/api/current/index.html#scala.util.Try) inspired gem that introduces `Try`s to Ruby while aiming for an idiomatic API. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'ytry' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install ytry 23 | 24 | ## Basic usage 25 | 26 | The Try type represents a computation that may either result in an error, or return a successfully computed value. 27 | 28 | If the block passed to Try runs with no errors, then a `Success` wrapping the computed value is returned. 29 | 30 | An instance of `Failure` wrapping the error is returned otherwise. 31 | 32 | ```ruby 33 | <<<<1, "name"=>"Lorenzo", "dob"=>"22/07/1985"}])') # COMMENT 29 | -------------------------------------------------------------------------------- /docs/data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"id": 1, "name": "Lorenzo", "dob": "22/07/1985"} 3 | ] -------------------------------------------------------------------------------- /docs/data/wrong_format.json: -------------------------------------------------------------------------------- 1 | { 2 | "1": {"id": 1, "name": "Lorenzo", "dob": "22/07/1985"} 3 | } -------------------------------------------------------------------------------- /docs/get_or_else.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test/test_helper' # IGNORE 2 | # IGNORE 3 | require 'test/unit' # IGNORE 4 | include Test::Unit::Assertions # IGNORE 5 | include Ytry # IGNORE 6 | # IGNORE 7 | invalid_json = "[\"missing_quote]" 8 | 9 | same_string( # IGNORE 10 | Try { JSON.parse(invalid_json) } 11 | .get_or_else{ [] } 12 | ).('[]') # COMMENT 13 | 14 | same_string( # IGNORE 15 | Try { JSON.parse("[]") } 16 | .get_or_else { fail "this block is ignored"} 17 | ).('[]') # COMMENT 18 | -------------------------------------------------------------------------------- /docs/setup.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test/test_helper' # IGNORE 2 | # IGNORE 3 | require 'test/unit' # IGNORE 4 | include Test::Unit::Assertions # IGNORE 5 | # IGNORE 6 | require 'ytry' 7 | include Ytry 8 | 9 | same_string( # IGNORE 10 | Try { 1 + 1 } 11 | ).('Success(2)') # COMMENT 12 | 13 | same_string( # IGNORE 14 | Try { 1 / 0 } 15 | ).('Failure(#)') # COMMENT 16 | -------------------------------------------------------------------------------- /lib/ytry.rb: -------------------------------------------------------------------------------- 1 | require 'ytry/version' 2 | 3 | module Ytry 4 | def Try 5 | raise ArgumentError, 'missing block' unless block_given? 6 | begin 7 | Success.new(yield) 8 | rescue StandardError => e 9 | Failure.new(e) 10 | end 11 | end 12 | module Try 13 | include Enumerable 14 | def self.ary_to_type value 15 | raise Try.invalid_argument('Argument must be an array-like object', value) unless value.respond_to? :to_ary 16 | return value if value.is_a? Try 17 | value.to_ary.empty? ? 18 | Failure.new(RuntimeError.new("Element not found").tap{|ex| ex.set_backtrace(caller)}) : 19 | Success.new(value.to_ary.first) 20 | end 21 | def self.zip *try_array 22 | first_failure = try_array.find(&:empty?) 23 | first_failure.nil? ? Success.new(try_array.map(&:get)) : first_failure 24 | end 25 | def each 26 | return enum_for(__method__) unless block_given? 27 | Try { yield self.get unless empty? } 28 | return self 29 | end 30 | alias_method :on_success, :each 31 | def on_failure 32 | return enum_for(__method__) unless block_given? 33 | return self 34 | end 35 | def map &block 36 | block or return enum_for(__method__) 37 | self.empty? ? self : Try{block.call(self.get)} 38 | end 39 | alias_method :collect, :map 40 | def select &block 41 | block or return enum_for(__method__) 42 | return self if empty? 43 | predicate = Try{ block.call(self.get) } 44 | return predicate if predicate.empty? 45 | predicate.get ? self : Try.ary_to_type([]) 46 | end 47 | def reject &block 48 | if block_given? then select {|v| ! block.call(v)} 49 | else enum_for(__method__) 50 | end 51 | end 52 | def flat_map &block 53 | block or return enum_for(method) 54 | return self if self.empty? 55 | wrapped_result = Try{block.call(self.get)} 56 | return wrapped_result if (!wrapped_result.empty? && !wrapped_result.get.respond_to?(:to_ary)) 57 | Try.ary_to_type(wrapped_result.flatten) 58 | end 59 | alias_method :collect_concat, :flat_map 60 | def grep pattern 61 | return self if self.empty? 62 | match = Try { 63 | (pattern === self.get) ? self.get : (raise RuntimeError.new("Element not found")) 64 | } 65 | block_given? ? match.map{|v| yield v} : match 66 | end 67 | def flatten level = 1 68 | level.times.reduce(self) { |current, _| 69 | break(current) if current.empty? 70 | Try.ary_to_type current.get 71 | } 72 | end 73 | def zip *others 74 | Try.zip(self, *others) 75 | end 76 | def | lambda 77 | self.flat_map &lambda # slow but easy to read + supports symbols out of the box 78 | end 79 | def or_else 80 | return self unless empty? 81 | candidate = Try{ yield } 82 | if (!candidate.empty? && !candidate.get.is_a?(Try)) 83 | raise(Try.invalid_argument('Block should evaluate to an instance of Try', candidate.get)) 84 | else 85 | candidate.flatten 86 | end 87 | end 88 | def get_or_else 89 | raise ArgumentError, 'missing block' unless block_given? 90 | return self.get unless empty? 91 | yield 92 | end 93 | def inspect() to_s end 94 | private 95 | def self.invalid_argument type_str, arg 96 | TypeError.new "#{type_str}. Found #{arg.class}" 97 | end 98 | end 99 | class Success 100 | include Try 101 | def initialize value 102 | @value = value.freeze 103 | end 104 | def get() @value end 105 | def empty?() false end 106 | def to_s() "Success(#{get})" end 107 | def to_ary() [get] end 108 | def == other 109 | other.is_a?(Success) && self.get == other.get 110 | end 111 | def === other 112 | other.is_a?(Success) && self.get === other.get 113 | end 114 | def recover &block 115 | raise ArgumentError, 'missing block' unless block_given? 116 | self 117 | end 118 | def recover_with &block 119 | raise ArgumentError, 'missing block' unless block_given? 120 | self 121 | end 122 | end 123 | class Failure 124 | include Try 125 | attr_reader :error 126 | def initialize value 127 | @error = value.freeze 128 | end 129 | def get() raise @error end 130 | def empty?() true end 131 | def to_s() "Failure(#{@error.inspect})" end 132 | def to_ary() [] end 133 | def == other 134 | other.is_a?(Failure) && self.error == other.error 135 | end 136 | def === other 137 | other.is_a?(Failure) && self.error === other.error 138 | end 139 | def on_failure 140 | return enum_for(__method__) unless block_given? 141 | Try { yield @error } 142 | return self 143 | end 144 | def recover &block 145 | raise ArgumentError, 'missing block' unless block_given? 146 | candidate = Success.new(@error).map &block 147 | (!candidate.empty? && candidate.get.nil?) ? self : candidate 148 | end 149 | def recover_with &block 150 | candidate = self.recover(&block) 151 | if (!candidate.empty? && !candidate.get.is_a?(Try)) 152 | raise(Try.invalid_argument('Block should evaluate to an instance of Try', candidate.get)) 153 | else 154 | candidate.flatten 155 | end 156 | end 157 | end 158 | end -------------------------------------------------------------------------------- /lib/ytry/version.rb: -------------------------------------------------------------------------------- 1 | module Ytry 2 | VERSION = "2.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/scala_try_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | include Ytry 4 | 5 | # The following tests are a port of TryTests.scala @ scala-2.11.8/test/files/jvm/future-spec/TryTests.scala 6 | describe "Try" do 7 | MyError = Class.new StandardError 8 | 9 | let(:error) { RuntimeError.new("TestError") } 10 | 11 | describe "Try{}" do 12 | it "catch exceptions and lift into the Try type" do 13 | Try{ 1 }.must_equal Success.new(1) 14 | Try{ raise error }.must_equal Failure.new(error) 15 | end 16 | end 17 | 18 | it "recover_with" do 19 | other_error = MyError.new 20 | Success.new(1).recover_with { Success.new(2) }.must_equal Success.new(1) 21 | Failure.new(error).recover_with { Success.new(2) }.must_equal Success.new(2) 22 | Failure.new(error).recover_with { Failure.new(other_error) }.must_equal Failure.new(other_error) 23 | end 24 | 25 | it "get_or_else" do 26 | Success.new(1).get_or_else{2}.must_equal 1 27 | Failure.new(error).get_or_else{2}.must_equal 2 28 | end 29 | 30 | it "or_else" do 31 | Success.new(1).or_else{ Success.new(2) }.must_equal Success.new(1) 32 | Failure.new(error).or_else{ Success.new(2) }.must_equal Success.new(2) 33 | end 34 | 35 | describe "map" do 36 | it "when there is no exception" do 37 | Success.new(1).map(&:succ).must_equal Success.new(2) 38 | Failure.new(error).map(&:succ).must_equal Failure.new(error) 39 | end 40 | 41 | it "when there is an exception" do 42 | Success.new(1).map{raise error}.must_equal Failure.new(error) 43 | 44 | other_error = MyError.new 45 | Failure.new(error).map{raise other_error}.must_equal Failure.new(error) 46 | end 47 | it "when there is a fatal exception" do 48 | -> {Success.new(1).map{raise SecurityError}} 49 | .must_raise SecurityError 50 | end 51 | end 52 | 53 | describe "flat_map" do 54 | it "when there is no exception" do 55 | Success.new(1).flat_map{|v| Success.new(v + 1)}.must_equal Success.new(2) 56 | Failure.new(error).flat_map{|v| Success.new(v + 1)}.must_equal Failure.new(error) 57 | end 58 | 59 | it "when there is an exception" do 60 | Success.new(1).flat_map{raise error}.must_equal Failure.new(error) 61 | 62 | other_error = MyError.new 63 | Failure.new(error).flat_map{raise other_error}.must_equal Failure.new(error) 64 | end 65 | it "when there is a fatal exception" do 66 | -> {Success.new(1).flat_map{raise SecurityError}} 67 | .must_raise SecurityError 68 | end 69 | end 70 | 71 | describe "flatten" do 72 | it "is a Success(Success)" do 73 | Success.new(Success.new(1)).flatten.must_equal Success.new(1) 74 | end 75 | 76 | it "is a Success(Failure)" do 77 | Success.new(Failure.new(error)) 78 | .flatten.must_equal Failure.new(error) 79 | end 80 | 81 | it "is a Failure" do 82 | Failure.new(error).flatten.must_equal Failure.new(error) 83 | end 84 | end 85 | 86 | # analogous to scala for-comprehension 87 | describe "flat_map + map" do 88 | it "returns Success when there are no failures" do 89 | a = Success.new(1) 90 | b = Success.new(2) 91 | 92 | a.flat_map{|va| b.map{|vb| va + vb}}.must_equal Success.new(3) 93 | end 94 | 95 | it "returns Failure when one of the callers is a failure" do 96 | a = Failure.new(error) 97 | b = Success.new(2) 98 | 99 | a.flat_map{|va| b.map{|vb| va + vb}}.must_equal Failure.new(error) 100 | b.flat_map{|vb| a.map{|va| vb + va}}.must_equal Failure.new(error) 101 | end 102 | 103 | it "returns the first occurring failure when there are multiple failures among the callers" do 104 | a = Failure.new(error) 105 | other_error = MyError.new 106 | b = Failure.new(other_error) 107 | 108 | a.flat_map{|va| b.map{|vb| va + vb}}.must_equal Failure.new(error) 109 | b.flat_map{|vb| a.map{|va| vb + va}}.must_equal Failure.new(other_error) 110 | end 111 | end 112 | end -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 5 | require 'ytry' 6 | 7 | require 'minitest/autorun' 8 | require 'minitest/unit' 9 | include MiniTest::Assertions 10 | 11 | @assertions = 0 12 | def assertions; @assertions; end 13 | def assertions= other; @assertions = other; end 14 | 15 | def same_string(exp1) 16 | -> exp2 {assert_equal(exp2, exp1.to_s); exp1} 17 | end 18 | 19 | def new_flag 20 | initial_value, current_value, toggle_value = -> {init = false; flag = init; [init, -> {flag}, -> {flag = !flag}]}.() 21 | end -------------------------------------------------------------------------------- /test/ytry_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | include Ytry 4 | 5 | describe 'Ytry module' do 6 | it 'should have a version' do 7 | refute_nil ::Ytry::VERSION 8 | end 9 | end 10 | 11 | describe 'Try' do 12 | it 'should wrap a successful computation into Success' do 13 | Try{'hello'}.must_equal Success.new('hello') 14 | end 15 | it 'should wrap an exception into Failure when an exception is raised' do 16 | Try{raise TypeError}.must_be_kind_of Failure 17 | end 18 | describe '.zip' do 19 | before do 20 | @all_success = (1..5).map{|i| Try {i}} 21 | @some_failure = (1..5).map{|i| i%2 == 0 ? Try{i} : Try{fail RuntimeError.new(i)}} 22 | end 23 | it 'should return a Success wrapping all the zipped values when there are no Failures' do 24 | Try.zip(*@all_success).must_equal Success.new((1..5).to_a) 25 | end 26 | it 'should otherwise return the first Failure it finds in the given sequence of Trys' do 27 | Try.zip(*@some_failure).must_be_kind_of Failure 28 | Try.zip(*@some_failure).recover{|e| e}.get.message.must_equal "1" 29 | end 30 | end 31 | end 32 | 33 | describe 'Success' do 34 | before do 35 | @success = Try{ 41 + 1 } 36 | @success_value = @success.get 37 | end 38 | it 'should evaluate the given block and return itself on #each/#on_success, even if the block raises an error' do 39 | initial_value, current_value, toggle_value = new_flag() 40 | @success.each{|v| toggle_value.()}.must_equal @success 41 | current_value.().must_equal !initial_value 42 | @success.on_success{|v| toggle_value.()}.must_equal @success 43 | current_value.().must_equal initial_value 44 | @success.each{|v| fail}.must_equal @success 45 | @success.on_success{|v| fail}.must_equal @success 46 | end 47 | it 'should return itself and not evaluate the given block on #on_failure' do 48 | initial_value, current_value, toggle_value = new_flag() 49 | @success.on_failure{toggle_value.()}.must_equal @success 50 | current_value.().must_equal initial_value 51 | end 52 | it 'should not support flattening a scalar value' do 53 | -> {@success.flatten}.must_raise TypeError 54 | end 55 | it 'should support `#flatten`/`#flat_map`' do 56 | Try{@success}.flatten.must_equal @success 57 | @success.map{|v| Try{v}}.flatten.must_equal @success 58 | @success.flat_map{|c| Try{c - 42}}.must_equal Try{0} 59 | @success.flat_map{|c| Try{raise TypeError}}.must_be_kind_of Failure 60 | end 61 | it 'should support flattening to the specified level' do 62 | triple_try = Try{Try{@success}} 63 | triple_try.flatten(-1).must_equal triple_try 64 | triple_try.flatten.get.must_equal @success 65 | triple_try.flatten(1).get.must_equal @success 66 | triple_try.flatten(2).must_equal @success 67 | -> { triple_try.flatten(3) }.must_raise TypeError 68 | end 69 | it 'should be forgiving when calling `#flat_map` on a Success wrapping a scalar value' do 70 | Try{1} | -> x {x/0} 71 | Try{1} | -> x {x} 72 | end 73 | it 'should alias #flat_map with #collect_concat' do 74 | @success.method(:flat_map).must_equal @success.method(:collect_concat) 75 | end 76 | 77 | describe 'select/reject' do 78 | it 'should return the caller when the given block returns true/false respectively' do 79 | @success.select(&:even?).must_equal @success 80 | @success.reject(&:odd?).must_equal @success 81 | end 82 | it 'should return a Failure wrapping a RuntimeError when the given block returns false/true respectively' do 83 | assert Failure.new(RuntimeError) === @success.select(&:odd?) 84 | assert Failure.new(RuntimeError) === @success.reject(&:even?) 85 | end 86 | it 'should return a Failure when the given block raises one' do 87 | assert Failure.new(TypeError) === @success.select{ raise TypeError } 88 | assert Failure.new(TypeError) === @success.reject{ raise TypeError } 89 | end 90 | end 91 | 92 | it 'should return itself on `#recover`/`#or_else`' do 93 | @success.recover{fail}.must_equal @success 94 | @success.or_else{fail}.must_equal @success 95 | end 96 | it 'should return the wrapped value on `#get_or_else`' do 97 | @success.get_or_else{fail}.must_equal @success_value 98 | end 99 | 100 | describe '#grep' do 101 | it 'should be equivalent to a #select + #map combo' do 102 | @success.grep(-> x {x.even?}).must_equal @success 103 | @success.grep(-> x {x.odd?}).must_be_kind_of Failure 104 | @success.grep(@success_value){:ok}.must_equal Success.new(:ok) 105 | @success.grep(1..@success_value){:ok}.must_equal Success.new(:ok) 106 | @success.grep(1..@success_value){fail}.must_be_kind_of Failure 107 | end 108 | 109 | it 'should return a Failure wrapping any error raised while matching' do 110 | MatchingError = Class.new StandardError 111 | ->{ @success.grep(-> v { raise MatchingError }){ 42 }.get }.must_raise MatchingError 112 | ->{ @success.grep(-> v { raise MatchingError }){|v| raise TypeError}.get }.must_raise MatchingError 113 | end 114 | 115 | it 'should return a Failure when no match is found' do 116 | ->{ @success.grep(-> v { false }){ 42 }.get }.must_raise RuntimeError 117 | ->{ @success.grep(-> v { false }){ raise TypeError }.get }.must_raise RuntimeError 118 | end 119 | 120 | it 'should return a Failure wrapping any error raised while running the given block' do 121 | BlockError = Class.new StandardError 122 | ->{ @success.grep(-> v { true }){|v| raise BlockError }.get }.must_raise BlockError 123 | end 124 | 125 | it 'should return a Try when the block is omitted' do 126 | @success.grep(-> v { true }).must_equal @success 127 | @success.grep(-> v { false }).must_be_kind_of Failure 128 | assert Failure.new(RuntimeError) === @success.grep(-> v { false }) 129 | end 130 | end 131 | 132 | it 'should support #zip' do 133 | @success.zip(@success).must_equal Success.new([@success_value] * 2) 134 | @success.zip(Try{fail}).must_be_kind_of Failure 135 | end 136 | end 137 | 138 | describe 'Failure' do 139 | before do 140 | @failure = Try{ 1 / 0 } 141 | @failure_type = @failure.error.class 142 | @failure_message = @failure.error.inspect 143 | end 144 | it 'should raise an exception on #get' do 145 | -> { @failure.get }.must_raise ZeroDivisionError 146 | end 147 | it 'should return the wrapped exception on #error' do 148 | Try{raise TypeError}.error.must_be_kind_of TypeError 149 | end 150 | it 'should support case statements' do 151 | case @failure 152 | when Failure then :ok 153 | else fail 154 | end 155 | case @failure 156 | when Failure.new(@failure_type) then :ok 157 | else fail 158 | end 159 | end 160 | it 'should support `#map`/`#collect`/`#select`' do 161 | @failure.map{|v| v + 1}.must_equal @failure 162 | @failure.collect(&:succ).must_equal @failure 163 | @failure.select{|x| x < 0}.must_equal @failure 164 | end 165 | it 'should support `#flatten`/`#flat_map`' do 166 | @failure.flatten.must_equal @failure 167 | Try{@failure}.flatten(-1).get.must_equal @failure 168 | Try{@failure}.flatten.must_equal @failure 169 | Try{@failure}.flatten(1).must_equal @failure 170 | Try{@failure}.flatten(2).must_equal @failure 171 | Try{@failure}.flatten(3).must_equal @failure 172 | Try{@failure}.flat_map{|c| c}.must_equal @failure 173 | end 174 | it 'should alias #flat_map with #collect_concat' do 175 | @failure.method(:flat_map).must_equal @failure.method(:collect_concat) 176 | end 177 | it 'should not evaluate the given block when calling enumerable methods' do 178 | initial_value, current_value, toggle_value = new_flag() 179 | @failure.each{|x| toggle_value.()}.must_equal @failure 180 | current_value.().must_equal initial_value 181 | @failure.any?{|x| toggle_value.(); x > 0}.must_equal false 182 | current_value.().must_equal initial_value 183 | @failure.all?{|x| toggle_value.(); x > 0}.must_equal true 184 | current_value.().must_equal initial_value 185 | @failure.include?(42).must_equal false 186 | @failure.reduce(42){toggle_value.(); raise RuntimeError}.must_equal 42 187 | @failure.each{|x| raise RuntimeError}.must_equal @failure 188 | end 189 | it 'should return itself and evaluate the given block on #on_failure' do 190 | initial_value, current_value, toggle_value = new_flag() 191 | @failure.on_failure{toggle_value.()}.must_equal @failure 192 | current_value.().must_equal !initial_value 193 | end 194 | it 'should return `other` on `#or_else`' do 195 | @failure.or_else {Try {1}}.get.must_equal 1 196 | end 197 | it 'should not raise an error to the caller if the given block raises one' do 198 | assert Failure.new(ArgumentError) === @failure.or_else { fail ArgumentError } 199 | end 200 | it 'should raise an error to the caller if the block does not return an instance of Try...' do 201 | -> { @failure.or_else { 1 } }.must_raise TypeError 202 | end 203 | it '... Even if the returned value is array-like' do 204 | -> { @failure.or_else { [1,2,3] } }.must_raise TypeError 205 | end 206 | it 'should return `other` on `#get_or_else`' do 207 | @failure.get_or_else {'lazily evaluated'}.must_equal "lazily evaluated" 208 | end 209 | it 'does not support passing an argument to #get_or_else' do 210 | -> {@failure.get_or_else(42)}.must_raise ArgumentError 211 | end 212 | it 'should be empty' do 213 | @failure.empty?.must_equal true 214 | end 215 | it 'should have a nice string representation' do 216 | @failure.to_s.must_equal "Failure(#{@failure_message})" 217 | end 218 | 219 | describe '#recover' do 220 | it 'turns a Failure into a Success when the given block evaluates with no errors' do 221 | @failure.recover{ 1 }.must_equal Success.new(1) 222 | end 223 | 224 | it "puts the error wrapped by the Failure in the block's scope for inspection" do 225 | @failure.recover{|e| e}.get.must_be_kind_of @failure_type 226 | end 227 | 228 | it 'should preserve the current error if the recover block returns nil' do 229 | @failure.recover{|e| case e; when RuntimeError then 1; end}.must_equal @failure 230 | end 231 | 232 | it 'should update the failure type when the recover block raises an error' do 233 | case @failure.recover{|e| raise RuntimeError} 234 | when Failure.new(@failure_type) then fail 235 | when Failure.new(RuntimeError) then :ok 236 | else fail 237 | end 238 | end 239 | end 240 | 241 | describe '#recover_with' do 242 | it 'is similar to #or_else...' do 243 | a_success = Success.new(0) 244 | other_failure = Failure.new(RuntimeError) 245 | @failure.recover_with{ other_failure }.must_equal other_failure 246 | @failure.recover_with{ a_success }.must_equal a_success 247 | end 248 | 249 | it '... but returns the caller when the block evaluates to nil - ' + 250 | 'so that we can match on the desired errors in a concise fashion' do 251 | @failure.recover_with{ nil }.must_equal @failure 252 | 253 | a_success = Success.new(42) 254 | @failure.recover_with{|e| a_success if e.is_a?(RuntimeError)} 255 | .must_equal @failure 256 | 257 | @failure.recover_with{|e| a_success if e.is_a?(@failure_type)} 258 | .must_equal a_success 259 | 260 | @failure.recover_with{|e| case e 261 | when RuntimeError then a_success end 262 | }.must_equal @failure 263 | end 264 | 265 | it 'will raise a TypeError when the block does not evaluate to a Try' do 266 | -> { @failure.recover_with{ [1,2,3] } }.must_raise TypeError 267 | -> { @failure.recover_with{ false } }.must_raise TypeError 268 | end 269 | 270 | it 'should update the failure type when the recover_with block raises an error' do 271 | case @failure.recover_with{ raise RuntimeError } 272 | when @failure then fail 273 | when Failure.new(RuntimeError) then :ok 274 | else fail 275 | end 276 | end 277 | end 278 | 279 | it 'should always return itself on #grep' do 280 | @failure.grep(->{true}).must_equal @failure 281 | end 282 | it 'should always return itself on #zip' do 283 | @failure.zip(Try{:ok}).must_equal @failure 284 | @failure.zip(Try{fail}).must_equal @failure 285 | end 286 | it 'should behave predictably when combining #recover and #flatten' do 287 | nested_try = @failure.recover{|e| Try{raise RuntimeError}} 288 | case nested_try 289 | when Success.new(Failure.new(RuntimeError)) then :ok 290 | else fail 291 | end 292 | case nested_try.flatten 293 | when Failure.new(RuntimeError) then :ok 294 | else fail 295 | end 296 | end 297 | end -------------------------------------------------------------------------------- /ytry.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'ytry/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "ytry" 8 | spec.version = Ytry::VERSION 9 | spec.authors = ["lorenzo.barasti"] 10 | spec.email = "ytry-user-group@googlegroups.com" 11 | 12 | spec.summary = %q{Scala-inspired Trys for the idiomatic Rubyist} 13 | spec.description = %q{"The Try type represents a computation that may either result in an exception, or return a successfully computed value." (From the scala-docs for scala.util.Try)} 14 | spec.homepage = "http://lbarasti.github.io/ytry" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").select { |f| f.match(%r{^(lib/|LICENSE|README)}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.required_ruby_version = '>= 2.0' 23 | 24 | spec.add_development_dependency "bundler", "~> 1.10" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "minitest", "~> 5.8" 27 | spec.add_development_dependency 'coveralls', '~> 0' 28 | end 29 | --------------------------------------------------------------------------------