# frozen_string_literal: true # A (partial) implementation of the functional Result type, with naming conventions based on the # Rust implementation (https://doc.rust-lang.org/std/result/index.html) # # Modern Ruby 3+ destructuring and pattern matching are supported. # # - See "Railway Oriented Programming and the Result Class" in `ee/lib/remote_development/README.md` for details # and example usage. # - See `spec/lib/gitlab/fp/result_spec.rb` for detailed executable example usage. # - See https://en.wikipedia.org/wiki/Result_type for a general description of the Result pattern. # - See https://fsharpforfunandprofit.com/rop/ for how this can be used with Railway Oriented Programming (ROP) # to improve design and architecture # - See https://doc.rust-lang.org/std/result/ for the Rust implementation. module Gitlab # noinspection RubyClassModuleNamingConvention -- JetBrains is changing this to allow shorter names module Fp class Result # The .ok and .err factory class methods are the only way to create a Result # # "self.ok" corresponds to Ok(T) in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#variant.Ok # # @param [Object, #new] ok_value # @return [Result] # noinspection MissingYardParamTag -- RubyMine does not recognize "duck type" Types # (https://rubydoc.info/gems/yard/file/docs/Tags.md#duck-types). This has been # reported to JetBrains - issue link pending def self.ok(ok_value) new(ok_value: ok_value) end # "self.err" corresponds to Err(E) in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#variant.Err # # @param [Object, #new] err_value # @return [Result] # noinspection MissingYardParamTag -- RubyMine does not recognize "duck type" Types # (https://rubydoc.info/gems/yard/file/docs/Tags.md#duck-types). This has been # reported to JetBrains - issue link pending def self.err(err_value) new(err_value: err_value) end # @param [Object, nil] ok_value # @param [Object, nil] err_value # @return [Object] def initialize(ok_value: nil, err_value: nil) if (!ok_value.nil? && !err_value.nil?) || (ok_value.nil? && err_value.nil?) raise(ArgumentError, 'Do not directly use private constructor, use Result.ok or Result.err') end @ok = err_value.nil? @value = ok? ? ok_value : err_value end private :initialize # "#unwrap" corresponds to "unwrap" in Rust. # # @return [Object] # @raise [RuntimeError] if called on an "err" Result def unwrap ok? ? value : raise("Called Result#unwrap on an 'err' Result") end # "#unwrap" corresponds to "unwrap" in Rust. # # @return [Object] # @raise [RuntimeError] if called on an "ok" Result def unwrap_err err? ? value : raise("Called Result#unwrap_err on an 'ok' Result") end # The `ok?` attribute is true if the Result was constructed with .ok, and false if it was constructed with .err # # "#ok?" corresponds to "is_ok" in Rust. # @return [Boolean] def ok? # We don't make `@ok` an attr_reader, because we don't want to confusingly shadow the class method `.ok` @ok end # The `err?` attribute is false if the Result was constructed with .ok, and true if it was constructed with .err # "#err?" corresponds to "is_err" in Rust. # # @return [Boolean] def err? !ok? end # `and_then` is a functional way to chain together operations which may succeed or have errors. It is passed # a lambda or class (singleton) method object, and must return a Result object representing "ok" # or "err". # # If the Result object it is called on is "ok", then the passed lambda or singleton method # is called with the value contained in the Result. # # If the Result object it is called on is "err", then it is returned without calling the passed # lambda or method. # # It only supports being passed a lambda, or a class (singleton) method object # which responds to `call` with a single argument (arity of 1). If multiple values are needed, # pass a hash or array. Note that passing `Proc` objects is NOT supported, even though the YARD # annotation contains `Proc` (because the type of a lambda is also `Proc`). # # Passing instance methods to `and_then` is not supported, because the methods in the chain should be # stateless "pure functions", and should not be persisting or referencing any instance state anyway. # # "#and_then" corresponds to "and_then" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.and_then # # @param [Proc, Method] lambda_or_singleton_method # @return [Result] # @raise [TypeError] def and_then(lambda_or_singleton_method) validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__) # Return/passthough the Result itself if it is an err return self if err? # If the Result is ok, call the lambda or singleton method with the contained value result = lambda_or_singleton_method.call(value) unless result.is_a?(Result) err_msg = "Result##{__method__} expects a lambda or singleton method object which returns a 'Result' " \ "type, but instead received '#{lambda_or_singleton_method.inspect}' which returned '#{result.class}'. " \ "Check that the previous method calls in the '#and_then' chain are correct." raise(TypeError, err_msg) end result end # `map` is similar to `and_then`, but it is used for "single track" methods which always succeed, # and have no possibility of returning an error (but they may still raise exceptions, # which is unrelated to the Result handling). The passed lambda or singleton method must return # a value, not a Result. # # If the Result object it is called on is "ok", then the passed lambda or singleton method # is called with the value contained in the Result. # # If the Result object it is called on is "err", then it is returned without calling the passed # lambda or method. # # "#map" corresponds to "map" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.map # # @param [Proc, Method] lambda_or_singleton_method # @return [Result] # @raise [TypeError] def map(lambda_or_singleton_method) validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__) # Return/passthrough the Result itself if it is an err return self if err? # If the Result is ok, call the lambda or singleton method with the contained value mapped_value = lambda_or_singleton_method.call(value) if mapped_value.is_a?(Result) err_msg = "Result##{__method__} expects a lambda or singleton method object which returns an unwrapped " \ "value, not a 'Result', but instead received '#{lambda_or_singleton_method.inspect}' which returned " \ "a 'Result'." raise(TypeError, err_msg) end # wrap the returned mapped_value in an "ok" Result. Result.ok(mapped_value) end # `map_err` is the inverse of `map`. It behaves identically, but it only processes `err` values # instead of `ok` values. # # "#map_err" corresponds to "map_err" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.map_err # # @param [Proc, Method] lambda_or_singleton_method # @return [Result] # @raise [TypeError] def map_err(lambda_or_singleton_method) validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__) # Return/passthrough the Result itself if it is an ok return self if ok? # If the Result is err, call the lambda or singleton method with the contained value mapped_value = lambda_or_singleton_method.call(value) if mapped_value.is_a?(Result) err_msg = "Result##{__method__} expects a lambda or singleton method object which returns an unwrapped " \ "value, not a 'Result', but instead received '#{lambda_or_singleton_method.inspect}' which returned " \ "a 'Result'." raise(TypeError, err_msg) end # wrap the returned mapped_value in an "err" Result. Result.err(mapped_value) end # `inspect_ok` is similar to `map`, becuase it receives the wrapped `ok` value, but it does not allow modification # of the value like `map`. The original result is always returned from `inspect_ok`. # # The passed lambda or singleton method must return, `nil`, to enforce the fact that the return value is ignored, # and the original Result is always returned. This corresponds to the `void` type in YARD/RBS type annotations, # and the `unit` type in Rust (https://doc.rust-lang.org/std/primitive.unit.html). # # If the passed method does not return `nil`, an error will be raised. # # "#inspect_ok" corresponds to "inspect" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.inspect # # If enforce_immutability is true, the passed method will not be allowed to mutate the value passed to it. # # Note that we could not call this method `inspect` to match the Rust Result.inspect function, because that # would conflict with the Kernel#inspect method in Ruby. # # @param [Proc, Method] lambda_or_singleton_method # @param [Boolean] enforce_immutability enforces immutability of the value passed to lambda_or_singleton_method # @return [Result] # @raise [TypeError] def inspect_ok(lambda_or_singleton_method, enforce_immutability: true) validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__) # Return/passthrough the Result itself if it is an err return self if err? # If the Result is ok, call the lambda or singleton method with the contained value call_and_optionally_enforce_value_is_not_mutated( callee: lambda_or_singleton_method, value: value, invoking_method: __method__, enforce_immutability: enforce_immutability ) # Return/passthrough the original Result self end # `inspect_err` is the inverse of `inspect_ok`. It behaves identically, but it only processes `err` values # instead of `ok` values. # # The passed lambda or singleton method must return, `nil`, to enforce the fact that the return value is ignored, # and the original Result is always returned. This corresponds to the `void` type in YARD/RBS type annotations, # and the `unit` type in Rust (https://doc.rust-lang.org/std/primitive.unit.html). # # If the passed method does not return `nil`, an error will be raised. # # If enforce_immutablity is true, the passed method will not be allowed to mutate the value passed to it. # # "#inspect_err" corresponds to "inspect_err" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.inspect_err # # @param [Proc, Method] lambda_or_singleton_method # @param [Boolean] enforce_immutability enforces immutability of the value passed to lambda_or_singleton_method # @return [Result] # @raise [TypeError] def inspect_err(lambda_or_singleton_method, enforce_immutability: true) validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__) # Return/passthrough the Result itself if it is an ok return self if ok? # If the Result is err, call the lambda or singleton method with the contained value call_and_optionally_enforce_value_is_not_mutated( callee: lambda_or_singleton_method, value: value, invoking_method: __method__, enforce_immutability: enforce_immutability ) # Return/passthrough the original Result self end # `to_h` supports destructuring of a result object, for example: `result => { ok: }; puts ok` # # @return [Hash] def to_h ok? ? { ok: value } : { err: value } end # `deconstruct_keys` supports pattern matching on a Result object with a `case` statement. See specs for examples. # # @param [Array] keys # @return [Hash] # @raise [ArgumentError] def deconstruct_keys(keys) raise(ArgumentError, 'Use either :ok or :err for pattern matching') unless [[:ok], [:err]].include?(keys) to_h end # @param [Result] other # @return [Boolean] def ==(other) # NOTE: The underlying `@ok` instance variable is a boolean, so we only need to check `ok?`, not `err?` too self.class == other.class && other.ok? == ok? && other.instance_variable_get(:@value) == value end private # The `value` attribute will contain either the ok_value or the err_value # # @return [Object] # noinspection RubyMismatchedReturnType def value # rubocop:disable Style/TrivialAccessors -- We are not using attr_reader here, so we can avoid nilability type errors in RubyMine # TODO: We are not using attr_reader here, so we can avoid nilability type errors in RubyMine. # This will be reported to JetBrains and tracked on # https://handbook.gitlab.com/handbook/tools-and-tips/editors-and-ides/jetbrains-ides/tracked-jetbrains-issues/, # this comment should then be updated with the issue link on that page. # Note that we don't use `noinspection` to suppress this error because there's several instances where # this error leaks to other classes through the call stack. @value end # @param [Proc, Method] callee # @param [Symbol] invoking_method # @return [void] # @raise [TypeError] def validate_lambda_or_singleton_method(callee:, invoking_method:) is_lambda = callee.is_a?(Proc) && callee.lambda? is_singleton_method = callee.is_a?(Method) && callee.owner.singleton_class? unless is_lambda || is_singleton_method err_msg = "Result##{invoking_method} expects a lambda or singleton method object, " \ "but instead received '#{callee.inspect}'." raise(TypeError, err_msg) end arity = callee.arity return if arity == 1 return if arity == -1 && callee.source_location[0].include?('rspec') err_msg = "Result##{invoking_method} expects a lambda or singleton method object with a single argument " \ "(arity of 1), but instead received '#{callee.inspect}' with an arity of #{arity}." raise(ArgumentError, err_msg) end # @param [Proc, Method] callee # @param [Object] value # @param [Symbol] invoking_method # @param [Boolean] enforce_immutability enforces immutability of the value passed to lambda_or_singleton_method # @return [void] # @raise [RuntimeError] def call_and_optionally_enforce_value_is_not_mutated(callee:, value:, invoking_method:, enforce_immutability:) return_value_from_call = if enforce_immutability # If enforce_immutability is true, deep clone and freeze the value before passing it, # so that the callee can't mutate the original value begin callee.call(deep_clone_and_freeze(value)) rescue FrozenError => e err_msg = "ERROR: #{callee} must not modify the passed value argument, because it was invoked via " \ "Result##{invoking_method}. Ensure that no side effects are being performed which modify any " \ "properties of nested objects, such as lazy memoization. " \ "Alternately, you can pass 'enforce_immutability: false' to Result##{invoking_method}." raise e.exception("#{e.message}. #{err_msg}") end else callee.call(value) end validate_return_value_is_void(return_value: return_value_from_call, invoking_method: invoking_method) end # @param [Object] object # @return [Object] def deep_clone_and_freeze(object) case object when Array object.map { |entry| deep_clone_and_freeze(entry) }.freeze when Hash object.transform_values { |entry| deep_clone_and_freeze(entry) }.freeze else object.clone(freeze: true) end end # @param [Proc, Method] return_value # @param [Symbol] invoking_method # @return [void] # @raise [TypeError] def validate_return_value_is_void(return_value:, invoking_method:) return if return_value.nil? err_msg = "The method passed to Result##{invoking_method} must always return 'nil' (void). This enforces " \ "that the return value is never used or modified. The existing 'Result' object is always passed along the " \ "chain unchanged. The return value received was '#{return_value.inspect}' instead of 'nil'." raise(TypeError, err_msg) end end end end