Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Recognize hash as optional arg when optional keyword is present #366

Merged
merged 3 commits into from
Mar 14, 2019
Merged

Recognize hash as optional arg when optional keyword is present #366

merged 3 commits into from
Mar 14, 2019

Conversation

edzhelyov
Copy link
Contributor

I hit an issue in one of the projects that I work that I've reported in rspec-mocks#1266.

In short, given foo(a, b = {}, x: false) should recognize a valid method call with args: foo(1, a: 1, b: 2).

I've opted to extend the spec for optional keywords. I'm feeling lost in the specs, so please, give me some pointers if you need the specs re-worked.

I'm OK if you want to change the implementation or try to catch edge cases involving splats.

@JonRowe
Copy link
Member

JonRowe commented Feb 26, 2019

Great start at fixing rspec-mocks#1266 I started trying to write a spec for it but got distracted.

What I would like to see here though is this being seperate cases demonstrating it in addition to our existing examples, as I want to ensure we don't regress other paths

@edzhelyov
Copy link
Contributor Author

Thanks, I will rework the specs.

Copy link
Contributor Author

@edzhelyov edzhelyov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JonRowe I've updated the PR. I've used the specs from a method with optional keyword arguments as a base and worked them out to handle the case with an optional argument.

During testing, I found that Ruby will treat each symbol key as a keyword argument if the method signature contains an optional keyword args. It will treat non-symbol arguments as a separate Hash. These are the reasons that I've worked the code in could_contain_kw_args? and split_args. See below some code samples how Ruby treats the hash arguments:

def foo(x, y = {}, z: 1) puts "#{y}, #{z}"; end
foo 1, 'a' => 2, 'b' => 2
{"a"=>2, "b"=>2}, 1
foo 1, 'a' => 2, :z => 3
{"a"=>2}, 3
foo 1, 'a' => 2, :b => 3
ArgumentError (unknown keyword: b)
foo 1, 'a' => 2, :b => 3, :z => 4
ArgumentError (unknown keyword: b)
foo 1, z: 3
{}, 3
foo 'a' => 1, 'b' => 2, z: 3
{}, 1

Waiting for your comments and possible edge cases and platforms that might behave differently.

@@ -85,6 +85,10 @@ def has_kw_args_in?(args)
# contain keyword arguments?
def could_contain_kw_args?(args)
return false if args.count <= min_non_kw_args
if args.count <= max_non_kw_args && Hash === args.last
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I've added Hash === args.last is that we use could_contain_kw_args? in https://github.com/rspec/rspec-support/blob/master/lib/rspec/support/method_signature_verifier.rb#L383 and I'm guarding against the matcher.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, this could be inlined into one if though?

Copy link
Member

@JonRowe JonRowe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No major feedback, just a few nits

@@ -85,6 +85,10 @@ def has_kw_args_in?(args)
# contain keyword arguments?
def could_contain_kw_args?(args)
return false if args.count <= min_non_kw_args
if args.count <= max_non_kw_args && Hash === args.last
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, this could be inlined into one if though?

Copy link
Contributor Author

@edzhelyov edzhelyov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JonRowe I've applied the style changes.

Given a method foo(a, b = {}, x: false) it should recognize that calling
foo(1, 'a' => 1, 'b' => 2) is a valid method call.

In case when the method has optional arguments and keyword arguments the
ruby interpreter will treat the last Hash as keyword arguments if it
contains Symbol keys. In the example above calling foo(1, a: 1) will
raise an ArgumentError.
If you pass key-value pairs to a method only the keys that are Symbols
will be treated as keyword arguments. If there are optional arguments
the rest of the keys will be passed to the optional one.

Given a method foo(x = {}, y: 1):
* foo('a' => 1) => will work
* foo('a' => 1, :y => 2) => will work
* foo('a' => 1, :b => 2) => will raise ArgumentError unknown keyword: b
@edzhelyov
Copy link
Contributor Author

@JonRowe, not sure if you have received notification, everything is passing now.

@@ -85,6 +85,10 @@ def has_kw_args_in?(args)
# contain keyword arguments?
def could_contain_kw_args?(args)
return false if args.count <= min_non_kw_args
return false if args.count <= max_non_kw_args &&
Hash === args.last &&
args.last.keys.none? { |x| x.is_a?(Symbol) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did this exceed the line length/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 125 characters in total.

last.keys
else
args << non_kw_args
last.select { |k, _| k.is_a?(Symbol) }.keys
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you saying with this that Ruby will mix keyword args and hash keys?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you provide an anonymous Hash as the last argument only the symbols from that argument will be treated as keyword arguments, the rest will be interpreted as positional one.

def foo(x = {}, y:); puts "#{x}, #{y}"; end

foo 'a' => 1, y: 2
=> {"a"=>1}, 2
foo x: 3, y: 2
=> ArgumentError (unknown keyword: x)

def bar(x:, y:); end

bar 'x' => 1
=> ArgumentError (wrong number of arguments (given 1, expected 0; required keywords: x, y))
bar x: 1, y: 2, z: 3
=> ArgumentError (unknown keyword: z)

I'm trying to cover the behavior in this spec

@JonRowe
Copy link
Member

JonRowe commented Mar 8, 2019

Sorry for the delay @edzhelyov my cognitive load has been a bit too high to deal with this until now

Copy link
Contributor Author

@edzhelyov edzhelyov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JonRowe no problem, review whenever you can, I'm grateful that you are helping me with moving that forward.

I've moved the symbol arguments logic into has_kw_args_in?, seems a more appropriate place. What do you think?

@@ -85,6 +85,10 @@ def has_kw_args_in?(args)
# contain keyword arguments?
def could_contain_kw_args?(args)
return false if args.count <= min_non_kw_args
return false if args.count <= max_non_kw_args &&
Hash === args.last &&
args.last.keys.none? { |x| x.is_a?(Symbol) }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 125 characters in total.

last.keys
else
args << non_kw_args
last.select { |k, _| k.is_a?(Symbol) }.keys
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you provide an anonymous Hash as the last argument only the symbols from that argument will be treated as keyword arguments, the rest will be interpreted as positional one.

def foo(x = {}, y:); puts "#{x}, #{y}"; end

foo 'a' => 1, y: 2
=> {"a"=>1}, 2
foo x: 3, y: 2
=> ArgumentError (unknown keyword: x)

def bar(x:, y:); end

bar 'x' => 1
=> ArgumentError (wrong number of arguments (given 1, expected 0; required keywords: x, y))
bar x: 1, y: 2, z: 3
=> ArgumentError (unknown keyword: z)

I'm trying to cover the behavior in this spec

@JonRowe JonRowe merged commit 3619fb6 into rspec:master Mar 14, 2019
JonRowe added a commit that referenced this pull request Mar 14, 2019
@JonRowe
Copy link
Member

JonRowe commented Mar 14, 2019

Thanks so much for your hard work!

JonRowe added a commit that referenced this pull request Oct 2, 2019
…ument

Recognize hash as optional arg when optional keyword is present
yujinakayama pushed a commit to yujinakayama/rspec-monorepo that referenced this pull request Oct 6, 2021
…ecognize-hash-as-optional-argument

Recognize hash as optional arg when optional keyword is present

---
This commit was imported from rspec/rspec-support@9601929.
yujinakayama pushed a commit to yujinakayama/rspec-monorepo that referenced this pull request Oct 6, 2021
…ecognize-hash-as-optional-argument

Recognize hash as optional arg when optional keyword is present

---
This commit was imported from rspec/rspec-support@9601929.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants