Skip to content

Commit f17342a

Browse files
authored
feat: Document custom arguments (#553)
1 parent 11bc434 commit f17342a

5 files changed

Lines changed: 289 additions & 2 deletions

File tree

config/sidebar.paper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ const paper: SidebarsConfig = {
144144
"dev/api/command-api/basics/registration",
145145
"dev/api/command-api/basics/requirements",
146146
"dev/api/command-api/basics/argument-suggestions",
147+
"dev/api/command-api/basics/custom-arguments",
147148
],
148149
},
149150
{
299 KB
Loading
242 KB
Loading
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
---
2+
slug: /dev/command-api/basics/custom-arguments
3+
description: Guide on custom arguments.
4+
---
5+
6+
import IceCreamPng from "./assets/ice-cream.png";
7+
import IceCreamInvalidPng from "./assets/ice-cream-invalid.png";
8+
9+
# Custom Arguments
10+
Custom arguments are nothing more than a wrapper around existing argument types, which allow a developer to provide an argument with suggestions and reusable parsing in order to
11+
reduce code repetition.
12+
13+
## Why would you use custom arguments?
14+
As example, if you want to have an argument for a player, which is currently online and an operator, you could use a player argument type, add custom suggestions, and throw a
15+
`CommandSyntaxException` in your `executes(...)` method body. This would look like this:
16+
17+
```java
18+
Commands.argument("player", ArgumentTypes.player())
19+
.suggests((ctx, builder) -> {
20+
Bukkit.getOnlinePlayers().stream()
21+
.filter(ServerOperator::isOp)
22+
.map(Player::getName)
23+
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(builder.getRemainingLowerCase()))
24+
.forEach(builder::suggest);
25+
return builder.buildFuture();
26+
})
27+
.executes(ctx -> {
28+
final Player player = ctx.getArgument("player", PlayerSelectorArgumentResolver.class).resolve(ctx.getSource()).getFirst();
29+
if (!player.isOp()) {
30+
final Message message = MessageComponentSerializer.message().serialize(text(player.getName() + " is not a server operator!"));
31+
throw new CommandSyntaxException(new SimpleCommandExceptionType(message), message);
32+
}
33+
34+
ctx.getSource().getSender().sendRichMessage("Player <player> is an operator!",
35+
Placeholder.component("player", player.displayName())
36+
);
37+
return Command.SINGLE_SUCCESS;
38+
})
39+
```
40+
41+
As you can see, there is a ton of logic not directly involved with the functionality of the command. And if we want to use this same argument on another node, we have to
42+
copy-paste a lot of code. It goes without saying that this would be incredibly tedious.
43+
44+
The solution to this problem are custom arguments. Before going into detail about them, this is how the argument would look when implemented as a custom argument:
45+
46+
```java title="OppedPlayerArgument.java"
47+
@NullMarked
48+
public final class OppedPlayerArgument implements CustomArgumentType<Player, PlayerSelectorArgumentResolver> {
49+
50+
@Override
51+
public Player parse(StringReader reader) {
52+
throw new UnsupportedOperationException("This method will never be called.");
53+
}
54+
55+
@Override
56+
public <S> Player parse(StringReader reader, S source) throws CommandSyntaxException {
57+
if (!(source instanceof CommandSourceStack stack)) {
58+
final Message message = MessageComponentSerializer.message().serialize(Component.text("The source needs to be a CommandSourceStack!"));
59+
throw new CommandSyntaxException(new SimpleCommandExceptionType(message), message);
60+
}
61+
62+
final Player player = getNativeType().parse(reader).resolve(stack).getFirst();
63+
if (!player.isOp()) {
64+
final Message message = MessageComponentSerializer.message().serialize(Component.text(player.getName() + " is not a server operator!"));
65+
throw new CommandSyntaxException(new SimpleCommandExceptionType(message), message);
66+
}
67+
68+
return player;
69+
}
70+
71+
@Override
72+
public ArgumentType<PlayerSelectorArgumentResolver> getNativeType() {
73+
return ArgumentTypes.player();
74+
}
75+
76+
@Override
77+
public <S> CompletableFuture<Suggestions> listSuggestions(CommandContext<S> ctx, SuggestionsBuilder builder) {
78+
Bukkit.getOnlinePlayers().stream()
79+
.filter(ServerOperator::isOp)
80+
.map(Player::getName)
81+
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(builder.getRemainingLowerCase()))
82+
.forEach(builder::suggest);
83+
return builder.buildFuture();
84+
}
85+
}
86+
```
87+
88+
At a first look, that seems like way more code than it was needed to just do the logic in the command tree itself. So what is the advantage?
89+
The answer becomes apparent rather quickly when we look at how the argument is now declared:
90+
91+
```java
92+
Commands.argument("player", new OppedPlayerArgument())
93+
.executes(ctx -> {
94+
final Player player = ctx.getArgument("player", Player.class);
95+
96+
ctx.getSource().getSender().sendRichMessage("Player <player> is an operator!",
97+
Placeholder.component("player", player.displayName())
98+
);
99+
return Command.SINGLE_SUCCESS;
100+
})
101+
```
102+
103+
This is way more readable and easy to understand when using a custom argument. And it is reusable! Hopefully, you now have a basic grasp of **why** you should use custom arguments.
104+
105+
## Examining the `CustomArgumentType` interface
106+
The interface is declared as follows:
107+
108+
```java title="CustomArgumentType.java"
109+
package io.papermc.paper.command.brigadier.argument;
110+
111+
@NullMarked
112+
public interface CustomArgumentType<T, N> extends ArgumentType<T> {
113+
114+
@Override
115+
T parse(final StringReader reader) throws CommandSyntaxException;
116+
117+
@Override
118+
default <S> T parse(final StringReader reader, final S source) throws CommandSyntaxException {
119+
return ArgumentType.super.parse(reader, source);
120+
}
121+
122+
ArgumentType<N> getNativeType();
123+
124+
@Override
125+
@ApiStatus.NonExtendable
126+
default Collection<String> getExamples() {
127+
return this.getNativeType().getExamples();
128+
}
129+
130+
@Override
131+
default <S> CompletableFuture<Suggestions> listSuggestions(final CommandContext<S> context, final SuggestionsBuilder builder) {
132+
return ArgumentType.super.listSuggestions(context, builder);
133+
}
134+
}
135+
```
136+
137+
### Generic types
138+
There are three generic types present in the interface:
139+
- `T`: This is the type of the class that is returned when `CommandContext#getArgument` is called on this argument.
140+
- `N`: The native type of the class which this custom argument extends. Used as the "underlying" argument.
141+
- `S`: A generic type for the command source. Will usually be a `CommandSourceStack`.
142+
143+
### Methods
144+
| Method declaration | Description |
145+
|---------------------------------------------------------------------------------------------------------------------------------|--------------|
146+
| `ArgumentType<N> getNativeType()` | Here, you declare the underlying argument type, which is used as a base for client-side argument validation. |
147+
| `T parse(final StringReader reader) throws CommandSyntaxException` | This method is used if `T parse(StringReader, S)` is not overridden. In here, you can run conversion and validation logic. |
148+
| `default <S> T parse(final StringReader reader, final S source)` | If overridden, this method will be preferred to `T parse(StringReader)`. It serves the same purpose, but allows including the source in the parsing logic.
149+
| `default Collection<String> getExamples()` | This method should **not** be overridden. It is used internally to differentiate certain argument types while parsing. |
150+
| `default <S> CompletableFuture<Suggestions> listSuggestions(final CommandContext<S> context, final SuggestionsBuilder builder)` | This method is the equivalent of `RequiredArgumentBuilder#suggests(SuggestionProvider<S>)`. You can override this method in order to send your own suggestions to the client. |
151+
152+
### A very basic implementation
153+
```java
154+
package io.papermc.commands;
155+
156+
import com.mojang.brigadier.StringReader;
157+
import com.mojang.brigadier.arguments.ArgumentType;
158+
import com.mojang.brigadier.arguments.StringArgumentType;
159+
import io.papermc.paper.command.brigadier.argument.CustomArgumentType;
160+
import org.jspecify.annotations.NullMarked;
161+
162+
@NullMarked
163+
public class BasicImplementation implements CustomArgumentType<String, String> {
164+
165+
@Override
166+
public String parse(StringReader reader) {
167+
return reader.readUnquotedString();
168+
}
169+
170+
@Override
171+
public ArgumentType<String> getNativeType() {
172+
return StringArgumentType.word();
173+
}
174+
}
175+
```
176+
177+
Notice the use of `reader.readUnquotedString()`. In addition to allowing existing argument types to parse your argument,
178+
you can also manually read input. Here, we read an unquoted string, the same as a word string argument type.
179+
180+
## `CustomArgumentType.Converted<T, N>`
181+
In case that you need to parse the native type to your new type, you can instead use the `CustomArgumentType.Converted` interface.
182+
This interface is an extension to the `CustomArgumentType` interface, which adds two new, overridable methods:
183+
184+
```java
185+
T convert(N nativeType) throws CommandSyntaxException;
186+
187+
default <S> T convert(final N nativeType, final S source) throws CommandSyntaxException {
188+
return this.convert(nativeType);
189+
}
190+
```
191+
192+
These methods work similarly to the `parse` methods, but they instead provide you with the parsed, native type instead of a `StringReader`.
193+
This reduced the need to manually do string reader operations and instead directly uses the native type's parsing rules.
194+
195+
## Error handling during the suggestions phase
196+
In case you are looking for the ability to make the client show currently typed input as red to display invalid input, it should be noted that this is **not possible** with
197+
custom arguments. The client is only able to validate arguments it knows about and there is no way to throw a `CommandSyntaxException` during the suggestions phase. The only way to
198+
achieve that is by using **literals**, but those cannot be modified dynamically during server runtime.
199+
200+
<div style={{display: 'inline-block', width: '100%'}}>
201+
<img src={IceCreamInvalidPng} style={{float: 'left', width: '100%'}}/>
202+
</div>
203+
204+
## Example: Ice-cream argument
205+
A practical example on how you can use a custom argument to your advantage could be a classical enum-type argument. In our case, we use this
206+
`IceCreamFlavor` enum:
207+
208+
```java title="IceCreamFlavor.java"
209+
package io.papermc.commands.icecream;
210+
211+
import org.jspecify.annotations.NullMarked;
212+
213+
@NullMarked
214+
public enum IceCreamFlavor {
215+
VANILLA,
216+
CHOCOLATE,
217+
STRAWBERRY;
218+
219+
@Override
220+
public String toString() {
221+
return name().toLowerCase();
222+
}
223+
}
224+
```
225+
226+
We then can use a converted custom argument type in order to convert between a word string argument and our enum type, like this:
227+
228+
```java title="IceCreamArgument.java"
229+
package io.papermc.commands.icecream;
230+
231+
@NullMarked
232+
public class IceCreamArgument implements CustomArgumentType.Converted<IceCreamFlavor, String> {
233+
234+
@Override
235+
public IceCreamFlavor convert(String nativeType) throws CommandSyntaxException {
236+
try {
237+
return IceCreamFlavor.valueOf(nativeType.toUpperCase(Locale.ROOT));
238+
}
239+
catch (IllegalArgumentException e) {
240+
final Message message = MessageComponentSerializer.message().serialize(Component.text(nativeType + " is not a valid flavor!"));
241+
throw new CommandSyntaxException(new SimpleCommandExceptionType(message), message);
242+
}
243+
}
244+
245+
@Override
246+
public <S> CompletableFuture<Suggestions> listSuggestions(CommandContext<S> context, SuggestionsBuilder builder) {
247+
for (IceCreamFlavor flavor : IceCreamFlavor.values()) {
248+
String name = flavor.toString();
249+
250+
// Only suggest if the flavor name matches the user input
251+
if (name.startsWith(builder.getRemainingLowerCase())) {
252+
builder.suggest(flavor.toString());
253+
}
254+
}
255+
256+
return builder.buildFuture();
257+
}
258+
259+
@Override
260+
public ArgumentType<String> getNativeType() {
261+
return StringArgumentType.word();
262+
}
263+
}
264+
```
265+
266+
Finally, we can just declare our command like this, and we are done! And again, you can just directly get the argument as a ready `IceCreamFlavor`
267+
type without any additional parsing in the `executes(...)` method, which makes custom argument types very powerful.
268+
269+
```java
270+
Commands.literal("icecream")
271+
.then(Commands.argument("flavor", new IceCreamArgument())
272+
.executes(ctx -> {
273+
final IceCreamFlavor flavor = ctx.getArgument("flavor", IceCreamFlavor.class);
274+
275+
ctx.getSource().getSender().sendRichMessage("<b><red>Y<green>U<aqua>M<light_purple>!</b> You just had a scoop of <flavor>!",
276+
Placeholder.unparsed("flavor", flavor.toString())
277+
);
278+
return Command.SINGLE_SUCCESS;
279+
})
280+
)
281+
.build();
282+
```
283+
284+
<div style={{display: 'inline-block', width: '100%'}}>
285+
<img src={IceCreamPng} style={{float: 'left', width: '100%'}}/>
286+
</div>

docs/paper/dev/api/command-api/basics/introduction.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The following sites are worth-while to look through first when learning about Br
3131
- [Command Registration](./registration)
3232
- [Command Requirements](./requirements)
3333
- [Argument Suggestions](./argument-suggestions)
34+
- [Custom Arguments](./custom-arguments)
3435

3536
For a reference of more advanced arguments, you should look here:
3637
- [Minecraft Arguments](../arguments/minecraft)
@@ -39,7 +40,6 @@ For a reference of more advanced arguments, you should look here:
3940

4041
The following pages will be added to the documentation in the future:
4142

42-
- **Custom Arguments**
4343
- **Tutorial: Creating Utility Commands**
4444
- **The Command Dispatcher**
4545
- **Forks and Redirects**
@@ -48,4 +48,4 @@ The following pages will be added to the documentation in the future:
4848
:::
4949

5050
## Additional support
51-
For support regarding the command API, you can always ask in our [Discord Server](https://discord.gg/PaperMC) in the `#paper-dev` channel!
51+
For support regarding the command API, you can always ask in our [Discord Server](https://discord.gg/PaperMC) in the `#paper-dev` channel!

0 commit comments

Comments
 (0)