require "constrain/version" module Constrain # Raised if types doesn't match a class expression class MatchError < StandardError def initialize(value, exprs, message: nil, unwind: 0, not_argument: false, not_value: nil) if not_argument super message || "Expected #{value.inspect} to not equal #{not_value.inspect}" else super message || "Expected #{value.inspect} to match #{Constrain.fmt_exprs(exprs)}" end end end def self.included base base.extend ClassMethods end # :call-seq: # constrain(value, *class-expressions, unwind: 0) # constrain(value, *values, unwind: 0) # # Check that value matches one of the class expressions. Raises a # ArgumentError if the expression is invalid and a Constrain::MatchError if # the value doesn't match. The exception's backtrace skips :unwind number of # entries def self.constrain(value, *exprs, **opts) do_constrain(value, *exprs, **opts) end # See Constrain.constrain def constrain(...) = Constrain.do_constrain(...) # Like #constrain but returns true/false to indicate the result instead of # raising an exception def self.constrain?(value, *exprs, **opts) do_constrain?(value, *exprs, **opts) end # See Constrain.constrain? def constrain?(...) = Constrain.do_constrain?(...) module ClassMethods # See Constrain.constrain def constrain(...) Constrain.do_constrain(...) end # See Constrain.constrain? def constrain?(...) Constrain.do_constrain?(...) end end # :call-seq: # do_constrain(value, *exprs, unwind: 0, message: nil, not: nil) # # unwind is automatically incremented by one because ::do_constrain is always # called from one of the other constrain methods # def self.do_constrain(value, *exprs, unwind: 0, message: nil) unwind += 1 begin if exprs.empty? value or raise MatchError.new(value, [], message: message, unwind: unwind) else exprs.any? { |expr| Constrain.do_constrain_value?(value, expr) } or raise MatchError.new(value, exprs, message: message, unwind: unwind) end rescue ArgumentError, Constrain::MatchError => ex ex.set_backtrace(caller[1 + unwind..-1]) raise end value end def self.do_constrain?(...) begin do_constrain(...) rescue MatchError return false end true end def self.do_constrain_value?(value, expr) case expr when Class, Module expr === value when Array !expr.empty? or raise ArgumentError, "Empty array in constraint" value.is_a?(Array) && value.all? { |elem| expr.any? { |e| Constrain.constrain?(elem, e) } } when Hash value.is_a?(Hash) && value.all? { |key, value| expr.any? { |key_expr, value_expr| [[key, key_expr], [value, value_expr]].all? { |value, expr| if expr.is_a?(Array) && (expr.size > 1 || expr.first.is_a?(Array)) expr.any? { |e| Constrain.do_constrain?(value, e) } else Constrain.constrain?(value, expr) end } } } when Proc expr.call(value) else expr === value end end # Render a class expression as a String. Same as # exprs.map(&:inspect).join(", ") except that Proc objects are rendered as # "Proc@:" def self.fmt_exprs(exprs) exprs.map { |expr| fmt_expr(expr) }.join(", ") end # Render a class expression as a String. Same as +expr.inspect+ except that # Proc objects are rendered as "Proc@>:" def self.fmt_expr(expr) case expr when Class, Module; expr.to_s when Regexp; expr.to_s when Array; "[" + expr.map { |expr| fmt_expr(expr) }.join(", ") + "]" when Hash; "{" + expr.map { |k,v| "#{fmt_expr(k)} => #{fmt_expr(v)}" }.join(", ") + "}" when Proc; "Proc@#{expr.source_location.first}:#{expr.source_location.last}" else expr.inspect end end end