Skip to content

Commit bc014eb

Browse files
committed
Fix error message when there are missing keyword arguments
1 parent 82cf606 commit bc014eb

File tree

10 files changed

+167
-23
lines changed

10 files changed

+167
-23
lines changed

spec/ruby/language/keyword_arguments_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ def m(*a, kw:)
6161
-> { m(kw: 1, a: 1, b: 2, c: 3) }.should raise_error(ArgumentError, 'unknown keywords: :a, :b, :c')
6262
end
6363

64+
it "raises ArgumentError exception when required keyword argument is not passed" do
65+
def m(a:, b:, c:)
66+
[a, b, c]
67+
end
68+
69+
-> { m(a: 1, b: 2) }.should raise_error(ArgumentError, /missing keyword: :c/)
70+
-> { m() }.should raise_error(ArgumentError, /missing keywords: :a, :b, :c/)
71+
end
72+
73+
it "raises ArgumentError for missing keyword arguments even if there are extra ones" do
74+
def m(a:)
75+
a
76+
end
77+
78+
-> { m(b: 1) }.should raise_error(ArgumentError, /missing keyword: :a/)
79+
end
80+
6481
it "handle * and ** at the same call site" do
6582
def m(*a)
6683
a

spec/ruby/language/method_spec.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,13 @@ def m(a:) a end
571571
end
572572
end
573573

574+
evaluate <<-ruby do
575+
def m(a:, **kw) [a, kw] end
576+
ruby
577+
578+
-> { m(b: 1) }.should raise_error(ArgumentError)
579+
end
580+
574581
evaluate <<-ruby do
575582
def m(a: 1) a end
576583
ruby

spec/ruby/language/proc_spec.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,11 @@
237237
end
238238
end
239239
end
240+
241+
describe "taking |required keyword arguments, **kw| arguments" do
242+
it "raises ArgumentError for missing required argument" do
243+
p = proc { |a:, **kw| [a, kw] }
244+
-> { p.call() }.should raise_error(ArgumentError)
245+
end
246+
end
240247
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fails:Keyword arguments raises ArgumentError for missing keyword arguments even if there are extra ones

src/main/java/org/truffleruby/core/exception/CoreExceptions.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,18 @@ public RubyException argumentErrorInvalidRadix(int radix, Node currentNode) {
207207
}
208208

209209
@TruffleBoundary
210-
public RubyException argumentErrorMissingKeyword(String name, Node currentNode) {
211-
return argumentError(StringUtils.format("missing keyword: %s", name), currentNode);
210+
public RubyException argumentErrorMissingKeywords(Object[] keys, Node currentNode) {
211+
if (keys.length == 1) {
212+
return argumentError("missing keyword: " + inspect(keys[0]), currentNode);
213+
}
214+
215+
final String[] names = new String[keys.length];
216+
217+
for (int i = 0; i < keys.length; i++) {
218+
names[i] = inspect(keys[i]);
219+
}
220+
221+
return argumentError("missing keywords: " + String.join(", ", names), currentNode);
212222
}
213223

214224
@TruffleBoundary

src/main/java/org/truffleruby/language/arguments/MissingKeywordArgumentNode.java

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,77 @@
99
*/
1010
package org.truffleruby.language.arguments;
1111

12+
import org.truffleruby.RubyLanguage;
13+
import org.truffleruby.core.exception.RubyException;
14+
import org.truffleruby.core.hash.RubyHash;
15+
import org.truffleruby.core.hash.library.HashStoreLibrary;
16+
import org.truffleruby.core.symbol.RubySymbol;
1217
import org.truffleruby.language.RubyContextSourceNode;
1318
import org.truffleruby.language.control.RaiseException;
1419

20+
import com.oracle.truffle.api.CompilerDirectives.CompilationFinal;
21+
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
1522
import com.oracle.truffle.api.frame.VirtualFrame;
23+
import org.truffleruby.language.methods.Arity;
1624

17-
public class MissingKeywordArgumentNode extends RubyContextSourceNode {
25+
import java.util.ArrayList;
26+
import java.util.Arrays;
27+
import java.util.List;
28+
import java.util.stream.Collectors;
1829

19-
private final String name;
30+
public class MissingKeywordArgumentNode extends RubyContextSourceNode {
31+
@CompilationFinal(dimensions = 1) private final RubySymbol[] requiredKeywords;
32+
@Child private ReadUserKeywordsHashNode readUserKeywordsHashNode;
33+
@Child private HashStoreLibrary hashes;
2034

21-
public MissingKeywordArgumentNode(String name) {
22-
this.name = name;
35+
public MissingKeywordArgumentNode(RubyLanguage language, Arity arity) {
36+
requiredKeywords = requiredKeywordsAsSymbols(language, arity);
37+
readUserKeywordsHashNode = new ReadUserKeywordsHashNode();
38+
hashes = insert(HashStoreLibrary.createDispatched());
2339
}
2440

2541
@Override
2642
public Object execute(VirtualFrame frame) {
27-
throw new RaiseException(getContext(), coreExceptions().argumentErrorMissingKeyword(name, this));
43+
RubyHash actualKeywords = readUserKeywordsHashNode.execute(frame);
44+
throw new RaiseException(getContext(), missingKeywordsError(actualKeywords));
45+
}
46+
47+
private RubySymbol[] requiredKeywordsAsSymbols(RubyLanguage language, Arity arity) {
48+
final String[] requiredKeywords = arity.getRequiredKeywordArguments();
49+
final RubySymbol[] symbols = new RubySymbol[requiredKeywords.length];
50+
51+
for (int i = 0; i < requiredKeywords.length; i++) {
52+
symbols[i] = language.getSymbol(requiredKeywords[i]);
53+
}
54+
55+
return symbols;
2856
}
2957

58+
@TruffleBoundary
59+
private RubyException missingKeywordsError(RubyHash actualKeywords) {
60+
final Object[] missingKeywords = findMissingKeywordArguments(actualKeywords);
61+
return coreExceptions().argumentErrorMissingKeywords(missingKeywords, this);
62+
}
63+
64+
@TruffleBoundary
65+
@SuppressWarnings("unchecked")
66+
private Object[] findMissingKeywordArguments(RubyHash actualKeywords) {
67+
if (actualKeywords == null) {
68+
return requiredKeywords;
69+
}
70+
71+
final ArrayList<Object> actualKeywordsAsList = new ArrayList<>();
72+
hashes.eachEntry(
73+
actualKeywords.store,
74+
actualKeywords,
75+
(index, key, value, state) -> ((ArrayList<Object>) state).add(key),
76+
actualKeywordsAsList);
77+
78+
final List<RubySymbol> requiredKeywordsAsList = Arrays.asList(requiredKeywords);
79+
final List<RubySymbol> missingKeywords = requiredKeywordsAsList.stream()
80+
.filter(k -> !actualKeywordsAsList.contains(k))
81+
.collect(Collectors.toList());
82+
83+
return missingKeywords.toArray();
84+
}
3085
}

src/main/java/org/truffleruby/language/arguments/ReadKeywordArgumentNode.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010
package org.truffleruby.language.arguments;
1111

12+
import com.oracle.truffle.api.CompilerDirectives;
1213
import com.oracle.truffle.api.frame.Frame;
1314
import org.truffleruby.collections.PEBiFunction;
1415
import com.oracle.truffle.api.dsl.ImportStatic;
@@ -23,6 +24,8 @@
2324
import org.truffleruby.language.RubyNode;
2425

2526
import com.oracle.truffle.api.frame.VirtualFrame;
27+
import org.truffleruby.language.RubyRootNode;
28+
import org.truffleruby.language.methods.Arity;
2629

2730
/** Read a single keyword argument or execute its default value expression if missing */
2831
@ImportStatic(HashGuards.class)
@@ -52,7 +55,7 @@ protected ReadKeywordArgumentNode(RubySymbol name, RubyNode defaultValue) {
5255

5356
@Specialization(guards = "hash == null")
5457
protected Object nullHash(VirtualFrame frame, RubyHash hash) {
55-
return defaultValue.execute(frame);
58+
return getDefaultValue().execute(frame);
5659
}
5760

5861
@Specialization(guards = "hash != null", limit = "hashStrategyLimit()")
@@ -72,6 +75,20 @@ public Object accept(Frame frame, Object hash, Object key) {
7275
// This only works if the library is always cached and does not reach the limit.
7376
// Since this node is never uncached, and the limit is >= number of strategies, it should hold.
7477
final VirtualFrame virtualFrame = (VirtualFrame) frame;
75-
return defaultValue.execute(virtualFrame);
78+
return getDefaultValue().execute(virtualFrame);
79+
}
80+
81+
RubyNode getDefaultValue() {
82+
if (defaultValue == null) {
83+
// This isn't a true default value - it's a marker to say there isn't one.
84+
// This actually makes sense; the semantic action of executing this node is to report an error,
85+
// and we do the same thing.
86+
CompilerDirectives.transferToInterpreterAndInvalidate();
87+
RubyRootNode rootNode = (RubyRootNode) getRootNode();
88+
Arity arity = rootNode.getSharedMethodInfo().getArity();
89+
defaultValue = insert(new MissingKeywordArgumentNode(getLanguage(), arity));
90+
}
91+
92+
return defaultValue;
7693
}
7794
}

src/main/java/org/truffleruby/language/methods/Arity.java

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ public final class Arity {
3333
private final boolean allKeywordsOptional;
3434
private final boolean hasKeywordsRest;
3535
private final String[] keywordArguments;
36+
private final int requiredKeywordArgumentsCount;
3637
/** During parsing we cannot know if this Arity object belongs to proc or to lambda. So we calculate the arity
3738
* number for both cases and provide a ProcType-dependent interface. */
3839
private final int arityNumber;
3940
private final int procArityNumber;
4041

4142
public Arity(int preRequired, int optional, boolean hasRest) {
42-
this(preRequired, optional, hasRest, 0, NO_KEYWORDS, true, false);
43+
this(preRequired, optional, hasRest, 0, NO_KEYWORDS, 0, false);
4344
}
4445

4546

@@ -49,14 +50,17 @@ public Arity(
4950
boolean hasRest,
5051
int postRequired,
5152
String[] keywordArguments,
52-
boolean allKeywordsOptional,
53+
int requiredKeywordArgumentsCount,
5354
boolean hasKeywordsRest) {
5455
this.preRequired = preRequired;
5556
this.optional = optional;
5657
this.hasRest = hasRest;
5758
this.postRequired = postRequired;
59+
// Required keywords are located at the beginning of the `keywordArguments` array.
60+
// So we can specify them with only one `int` field (`requiredKeywordArgumentsCount`).
5861
this.keywordArguments = keywordArguments;
59-
this.allKeywordsOptional = allKeywordsOptional;
62+
this.requiredKeywordArgumentsCount = requiredKeywordArgumentsCount;
63+
this.allKeywordsOptional = requiredKeywordArgumentsCount == 0;
6064
this.hasKeywordsRest = hasKeywordsRest;
6165
this.arityNumber = computeArityNumber(false);
6266
this.procArityNumber = computeArityNumber(true);
@@ -71,7 +75,7 @@ public Arity withRest(boolean hasRest) {
7175
hasRest,
7276
postRequired,
7377
keywordArguments,
74-
allKeywordsOptional,
78+
requiredKeywordArgumentsCount,
7579
hasKeywordsRest);
7680
}
7781

@@ -82,7 +86,7 @@ public Arity consumingFirstRequired() {
8286
hasRest,
8387
postRequired,
8488
keywordArguments,
85-
allKeywordsOptional,
89+
requiredKeywordArgumentsCount,
8690
hasKeywordsRest);
8791
}
8892

@@ -117,6 +121,10 @@ public boolean hasKeywords() {
117121
return keywordArguments.length != 0;
118122
}
119123

124+
public boolean hasRequiredKeywords() {
125+
return !allKeywordsOptional;
126+
}
127+
120128
public boolean hasKeywordsRest() {
121129
return hasKeywordsRest;
122130
}
@@ -147,6 +155,16 @@ public String[] getKeywordArguments() {
147155
return keywordArguments;
148156
}
149157

158+
public String[] getRequiredKeywordArguments() {
159+
final String[] requiredKeywords = new String[requiredKeywordArgumentsCount];
160+
161+
for (int i = 0; i < requiredKeywords.length; i++) {
162+
requiredKeywords[i] = keywordArguments[i];
163+
}
164+
165+
return requiredKeywords;
166+
}
167+
150168
public ArgumentDescriptor[] toAnonymousArgumentDescriptors() {
151169
List<ArgumentDescriptor> descs = new ArrayList<>();
152170

src/main/java/org/truffleruby/parser/LoadArgumentsTranslator.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import org.truffleruby.language.arguments.ArrayIsAtLeastAsLargeAsNode;
2828
import org.truffleruby.language.arguments.CheckNoKeywordArgumentsNode;
2929
import org.truffleruby.language.arguments.MissingArgumentBehavior;
30-
import org.truffleruby.language.arguments.MissingKeywordArgumentNode;
3130
import org.truffleruby.language.arguments.ReadKeywordArgumentNode;
3231
import org.truffleruby.language.arguments.ReadKeywordRestArgumentNode;
3332
import org.truffleruby.language.arguments.ReadOptionalArgumentNode;
@@ -273,9 +272,7 @@ public RubyNode visitKeywordArgNode(KeywordArgParseNode node) {
273272

274273
final RubyNode defaultValue;
275274
if (asgnNode.getValueNode() instanceof RequiredKeywordArgumentValueParseNode) {
276-
/* This isn't a true default value - it's a marker to say there isn't one. This actually makes sense; the
277-
* semantic action of executing this node is to report an error, and we do the same thing. */
278-
defaultValue = new MissingKeywordArgumentNode(name);
275+
defaultValue = null;
279276
} else {
280277
defaultValue = translateNodeOrNil(sourceSection, asgnNode.getValueNode());
281278
}

src/main/java/org/truffleruby/parser/ast/ArgsParseNode.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
package org.truffleruby.parser.ast;
3636

37+
import java.util.ArrayList;
3738
import java.util.Arrays;
3839
import java.util.List;
3940

@@ -125,24 +126,38 @@ public ArgsParseNode(
125126

126127
private Arity createArity() {
127128
final String[] keywordArguments;
128-
boolean allKeywordsOptional = true;
129+
final int requiredKeywordArgumentsCount;
129130

130131
if (getKeywordCount() > 0) {
132+
final List<String> requiredKeywords = new ArrayList<>();
133+
final List<String> optionalKeywords = new ArrayList<>();
134+
131135
final ParseNode[] keywordNodes = getKeywords().children();
132136
final int keywordsCount = keywordNodes.length;
133-
keywordArguments = new String[keywordsCount];
134137

135138
for (int i = 0; i < keywordsCount; i++) {
136139
final KeywordArgParseNode kwarg = (KeywordArgParseNode) keywordNodes[i];
137140
final AssignableParseNode assignableNode = kwarg.getAssignable();
138141
if (!(assignableNode instanceof LocalAsgnParseNode || assignableNode instanceof DAsgnParseNode)) {
139142
throw new UnsupportedOperationException("unsupported keyword arg " + kwarg);
140143
}
141-
keywordArguments[i] = ((INameNode) assignableNode).getName();
142-
allKeywordsOptional &= !Helpers.isRequiredKeywordArgumentValueNode(assignableNode);
144+
145+
final String keyword = ((INameNode) assignableNode).getName();
146+
if (Helpers.isRequiredKeywordArgumentValueNode(assignableNode)) {
147+
requiredKeywords.add(keyword);
148+
} else {
149+
optionalKeywords.add(keyword);
150+
}
143151
}
152+
153+
final List<String> keywords = new ArrayList<>(requiredKeywords);
154+
keywords.addAll(optionalKeywords);
155+
156+
keywordArguments = keywords.toArray(String[]::new);
157+
requiredKeywordArgumentsCount = requiredKeywords.size();
144158
} else {
145159
keywordArguments = Arity.NO_KEYWORDS;
160+
requiredKeywordArgumentsCount = 0;
146161
}
147162

148163
return new Arity(
@@ -151,7 +166,7 @@ private Arity createArity() {
151166
hasRestArg(),
152167
getPostCount(),
153168
keywordArguments,
154-
allKeywordsOptional,
169+
requiredKeywordArgumentsCount,
155170
hasKeyRest());
156171
}
157172

0 commit comments

Comments
 (0)