diff --git a/lib/contracts.rb b/lib/contracts.rb index f7367d5..d4c447a 100644 --- a/lib/contracts.rb +++ b/lib/contracts.rb @@ -1,6 +1,7 @@ require "contracts/builtin_contracts" require "contracts/decorators" require "contracts/errors" +require "contracts/error_formatter" require "contracts/formatters" require "contracts/invariants" require "contracts/method_reference" @@ -115,22 +116,7 @@ def to_s # This function is used by the default #failure_callback method # and uses the hash passed into the failure_callback method. def self.failure_msg(data) - expected = Contracts::Formatters::Expected.new(data[:contract]).contract - position = Contracts::Support.method_position(data[:method]) - method_name = Contracts::Support.method_name(data[:method]) - - header = if data[:return_value] - "Contract violation for return value:" - else - "Contract violation for argument #{data[:arg_pos]} of #{data[:total_args]}:" - end - - %{#{header} - Expected: #{expected}, - Actual: #{data[:arg].inspect} - Value guarded in: #{data[:class]}::#{method_name} - With Contract: #{data[:contracts]} - At: #{position} } + Contracts::ErrorFormatters.failure_msg(data) end # Callback for when a contract fails. By default it raises diff --git a/lib/contracts/error_formatter.rb b/lib/contracts/error_formatter.rb new file mode 100644 index 0000000..94d4834 --- /dev/null +++ b/lib/contracts/error_formatter.rb @@ -0,0 +1,121 @@ +module Contracts + class ErrorFormatters + def self.failure_msg(data) + class_for(data).new(data).message + end + + def self.class_for(data) + return Contracts::KeywordArgsErrorFormatter if keyword_args?(data) + DefaultErrorFormatter + end + + def self.keyword_args?(data) + data[:contract].is_a?(Contracts::Builtin::KeywordArgs) && data[:arg].is_a?(Hash) + end + end + + class DefaultErrorFormatter + attr_accessor :data + def initialize(data) + @data = data + end + + def message + %{#{header} + Expected: #{expected}, + Actual: #{data[:arg].inspect} + Value guarded in: #{data[:class]}::#{method_name} + With Contract: #{data[:contracts]} + At: #{position} } + end + + private + + def header + if data[:return_value] + "Contract violation for return value:" + else + "Contract violation for argument #{data[:arg_pos]} of #{data[:total_args]}:" + end + end + + def expected + Contracts::Formatters::Expected.new(data[:contract]).contract + end + + def position + Contracts::Support.method_position(data[:method]) + end + + def method_name + Contracts::Support.method_name(data[:method]) + end + end + + class KeywordArgsErrorFormatter < DefaultErrorFormatter + def message + s = [] + s << "#{header}" + s << " Expected: #{expected}" + s << " Actual: #{data[:arg].inspect}" + s << " Missing Contract: #{missing_contract_info}" unless missing_contract_info.empty? + s << " Invalid Args: #{invalid_args_info}" unless invalid_args_info.empty? + s << " Missing Args: #{missing_args_info}" unless missing_args_info.empty? + s << " Value guarded in: #{data[:class]}::#{method_name}" + s << " With Contract: #{data[:contracts]}" + s << " At: #{position} " + + s.join("\n") + end + + private + + def missing_args_info + @missing_args_info ||= begin + missing_keys = contract_options.keys - arg.keys + contract_options.select do |key, _| + missing_keys.include?(key) + end + end + end + + def missing_contract_info + @missing_contract_info ||= begin + contract_keys = contract_options.keys + arg.select { |key, _| !contract_keys.include?(key) } + end + end + + def invalid_args_info + @invalid_args_info ||= begin + invalid_keys = [] + arg.each do |key, value| + contract = contract_options[key] + next unless contract + invalid_keys.push(key) unless check_contract(contract, value) + end + invalid_keys.map do |key| + {key => arg[key], :contract => contract_options[key] } + end + end + end + + def check_contract(contract, value) + if contract.respond_to?(:valid?) + contract.valid?(value) + else + value.is_a?(contract) + end + rescue + false + end + + def contract_options + @contract_options ||= data[:contract].send(:options) + end + + def arg + data[:arg] + end + end +end diff --git a/spec/error_formatter_spec.rb b/spec/error_formatter_spec.rb new file mode 100644 index 0000000..c9023d4 --- /dev/null +++ b/spec/error_formatter_spec.rb @@ -0,0 +1,68 @@ +RSpec.describe "Contracts::ErrorFormatters" do + before :all do + @o = GenericExample.new + end + C = Contracts::Builtin + + describe "self.class_for" do + let(:keywordargs_contract) { C::KeywordArgs[:name => String, :age => Fixnum] } + let(:other_contract) { [C::Num, C::Num, C::Num] } + + it "returns KeywordArgsErrorFormatter for KeywordArgs contract" do + data_keywordargs = {:contract => keywordargs_contract, :arg => {:b => 2}} + expect(Contracts::ErrorFormatters.class_for(data_keywordargs)).to eq(Contracts::KeywordArgsErrorFormatter) + end + + it "returns Contracts::DefaultErrorFormatter for other contracts" do + data_default = {:contract => other_contract, :arg => {:b => 2}} + expect(Contracts::ErrorFormatters.class_for(data_default)).to eq(Contracts::DefaultErrorFormatter) + end + end + + def format_message(str) + str.split("\n").map(&:strip).join("\n") + end + + def fails(msg, &block) + expect { block.call }.to raise_error do |e| + expect(e).to be_a(ParamContractError) + expect(format_message(e.message)).to include(format_message(msg)) + end + end + + if ruby_version > 1.8 + describe "self.failure_msg" do + it "includes normal information" do + msg = %{Contract violation for argument 1 of 1: + Expected: (KeywordArgs[{:name=>String, :age=>Fixnum}]) + Actual: {:age=>"2", :invalid_third=>1} + Missing Contract: {:invalid_third=>1} + Invalid Args: [{:age=>"2", :contract=>Fixnum}] + Missing Args: {:name=>String} + Value guarded in: GenericExample::simple_keywordargs + With Contract: KeywordArgs => NilClass} + fails msg do + @o.simple_keywordargs(:age => "2", :invalid_third => 1) + end + end + + it "includes Missing Contract information" do + fails %{Missing Contract: {:invalid_third=>1, :invalid_fourth=>1}} do + @o.simple_keywordargs(:age => "2", :invalid_third => 1, :invalid_fourth => 1) + end + end + + it "includes Invalid Args information" do + fails %{Invalid Args: [{:age=>"2", :contract=>Fixnum}]} do + @o.simple_keywordargs(:age => "2", :invalid_third => 1) + end + end + + it "includes Missing Args information" do + fails %{Missing Args: {:name=>String}} do + @o.simple_keywordargs(:age => "2", :invalid_third => 1) + end + end + end + end +end diff --git a/spec/fixtures/fixtures.rb b/spec/fixtures/fixtures.rb index b6d2bea..4272a2d 100644 --- a/spec/fixtures/fixtures.rb +++ b/spec/fixtures/fixtures.rb @@ -132,6 +132,10 @@ def person_keywordargs(name, age) def hash_keywordargs(data) end + Contract C::KeywordArgs[:name => String, :age => Fixnum] => nil + def simple_keywordargs(data) + end + Contract (/foo/) => nil def should_contain_foo(s) end