Skip to content

Commit a350a8d

Browse files
authored
Merge pull request #305 from egonSchiele/fix/keyword-arguments-with-optional-positional-arguments
Fix keyword arguments contract when used with optional positional arguments
2 parents b1f5291 + 02fc8ef commit a350a8d

File tree

3 files changed

+96
-27
lines changed

3 files changed

+96
-27
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
Feature: KeywordArgs when used with optional positional arguments
2+
3+
Checks that the argument is an options hash, and all required keyword arguments are present, and all values pass their respective contracts
4+
5+
```ruby
6+
Contract Any, KeywordArgs[:number => Num, :description => Optional[String]] => Any
7+
```
8+
9+
Background:
10+
Given a file named "keyword_args_with_optional_positional_args_usage.rb" with:
11+
"""ruby
12+
require "contracts"
13+
C = Contracts
14+
15+
class Example
16+
include Contracts::Core
17+
18+
Contract C::Any, String, C::KeywordArgs[b: C::Optional[String]] => Symbol
19+
def foo(output, a = 'a', b: 'b')
20+
p [a, b]
21+
output
22+
end
23+
end
24+
"""
25+
26+
Scenario: Accepts arguments when only require arguments filled and valid
27+
Given a file named "accepts_all_filled_valid_args.rb" with:
28+
"""ruby
29+
require "./keyword_args_with_optional_positional_args_usage"
30+
puts Example.new.foo(:output)
31+
"""
32+
When I run `ruby accepts_all_filled_valid_args.rb`
33+
Then output should contain:
34+
"""
35+
["a", "b"]
36+
output
37+
"""
38+
39+
Scenario: Accepts arguments when all filled and valid
40+
Given a file named "accepts_all_filled_valid_args.rb" with:
41+
"""ruby
42+
require "./keyword_args_with_optional_positional_args_usage"
43+
puts Example.new.foo(:output, 'c', b: 'd')
44+
"""
45+
When I run `ruby accepts_all_filled_valid_args.rb`
46+
Then output should contain:
47+
"""
48+
["c", "d"]
49+
output
50+
"""
51+
52+
Scenario: Accepts arguments when only require arguments & optional keyword arguments filled and valid
53+
Given a file named "accepts_all_filled_valid_args.rb" with:
54+
"""ruby
55+
require "./keyword_args_with_optional_positional_args_usage"
56+
puts Example.new.foo(:output, b: 'd')
57+
"""
58+
When I run `ruby accepts_all_filled_valid_args.rb`
59+
Then output should contain:
60+
"""
61+
["a", "d"]
62+
output
63+
"""
64+
65+
Scenario: Accepts arguments when only require arguments & optional positional arguments filled and valid
66+
Given a file named "accepts_all_filled_valid_args.rb" with:
67+
"""ruby
68+
require "./keyword_args_with_optional_positional_args_usage"
69+
puts Example.new.foo(:output, 'c')
70+
"""
71+
When I run `ruby accepts_all_filled_valid_args.rb`
72+
Then output should contain:
73+
"""
74+
["c", "b"]
75+
output
76+
"""

lib/contracts.rb

+6-24
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class Contract < Contracts::Decorator
5353
end
5454
end
5555

56-
attr_reader :args_contracts, :ret_contract, :klass, :method
56+
attr_reader :args_contracts, :kargs_contract, :ret_contract, :klass, :method
5757

5858
def initialize(klass, method, *contracts)
5959
super(klass, method)
@@ -70,13 +70,18 @@ def initialize(klass, method, *contracts)
7070

7171
# internally we just convert that return value syntax back to an array
7272
@args_contracts = contracts[0, contracts.size - 1] + contracts[-1].keys
73+
# Extract contract for keyword arguments
74+
@kargs_contract = args_contracts.find { |c| c.is_a?(Contracts::Builtin::KeywordArgs) }
75+
args_contracts.delete(kargs_contract) if kargs_contract
7376

7477
@ret_contract = contracts[-1].values[0]
7578

7679
@args_validators = args_contracts.map do |contract|
7780
Contract.make_validator(contract)
7881
end
7982

83+
@kargs_validator = kargs_contract ? Contract.make_validator(kargs_contract) : nil
84+
8085
@args_contract_index = args_contracts.index do |contract|
8186
contract.is_a? Contracts::Args
8287
end
@@ -94,16 +99,6 @@ def initialize(klass, method, *contracts)
9499

95100
# ====
96101

97-
# == @has_options_contract
98-
last_contract = args_contracts.last
99-
penultimate_contract = args_contracts[-2]
100-
@has_options_contract = if @has_proc_contract
101-
penultimate_contract.is_a?(Contracts::Builtin::KeywordArgs)
102-
else
103-
last_contract.is_a?(Contracts::Builtin::KeywordArgs)
104-
end
105-
# ===
106-
107102
@klass, @method = klass, method
108103
end
109104

@@ -256,19 +251,6 @@ def maybe_append_block! args, blk
256251
true
257252
end
258253

259-
# Same thing for when we have named params but didn't pass any in.
260-
# returns true if it appended nil
261-
def maybe_append_options! args, kargs, blk
262-
return false unless @has_options_contract
263-
264-
if @has_proc_contract && args_contracts[-2].is_a?(Contracts::Builtin::KeywordArgs)
265-
args.insert(-2, kargs)
266-
elsif args_contracts[-1].is_a?(Contracts::Builtin::KeywordArgs)
267-
args << kargs
268-
end
269-
true
270-
end
271-
272254
# Used to determine type of failure exception this contract should raise in case of failure
273255
def failure_exception
274256
if pattern_match?

lib/contracts/call_with.rb

+14-3
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,20 @@ def call_with_inner(returns, this, *args, **kargs, &blk)
1212
# Explicitly append blk=nil if nil != Proc contract violation anticipated
1313
nil_block_appended = maybe_append_block!(args, blk)
1414

15-
# Explicitly append options={} if Hash contract is present
16-
kargs_appended = maybe_append_options!(args, kargs, blk)
15+
if @kargs_validator && !@kargs_validator[kargs]
16+
data = {
17+
arg: kargs,
18+
contract: kargs_contract,
19+
class: klass,
20+
method: method,
21+
contracts: self,
22+
arg_pos: :keyword,
23+
total_args: args.size,
24+
return_value: false,
25+
}
26+
return ParamContractError.new("as return value", data) if returns
27+
return unless Contract.failure_callback(data)
28+
end
1729

1830
# Loop forward validating the arguments up to the splat (if there is one)
1931
(@args_contract_index || args.size).times do |i|
@@ -84,7 +96,6 @@ def call_with_inner(returns, this, *args, **kargs, &blk)
8496
# If we put the block into args for validating, restore the args
8597
# OR if we added a fake nil at the end because a block wasn't passed in.
8698
args.slice!(-1) if blk || nil_block_appended
87-
args.slice!(-1) if kargs_appended
8899
result = if method.respond_to?(:call)
89100
# proc, block, lambda, etc
90101
method.call(*args, **kargs, &blk)

0 commit comments

Comments
 (0)