> parse(
+ final @NonNull CommandContext<@NonNull C> commandContext,
+ final @NonNull CommandInput commandInput
+ ) {
+ final String input = commandInput.skipWhitespace().peekString();
+
+ final LocationCoordinateType coordinateType;
+ if (commandInput.peek() == '~') {
+ coordinateType = LocationCoordinateType.RELATIVE;
+ commandInput.moveCursor(1);
+ } else {
+ coordinateType = LocationCoordinateType.ABSOLUTE;
+ }
+
+ final double coordinate;
+ try {
+ final boolean empty = commandInput.peekString().isEmpty() || commandInput.peek() == ' ';
+ coordinate = empty ? 0 : commandInput.readDouble();
+ if (commandInput.hasRemainingInput()) {
+ commandInput.skipWhitespace();
+ }
+ } catch (final Exception e) {
+ return ArgumentParseResult.failure(new DoubleParser.DoubleParseException(
+ input,
+ new DoubleParser<>(DoubleParser.DEFAULT_MINIMUM, DoubleParser.DEFAULT_MAXIMUM),
+ commandContext
+ ));
+ }
+
+ return ArgumentParseResult.success(LocationCoordinate.of(coordinateType, coordinate));
+ }
+
+ /**
+ * Returns the final coordinate value against an origin.
+ *
+ * @param origin the origin value (x/y/z of sender etc)
+ * @return the final coordinate
+ */
+ double resolve(final double origin) {
+ return this.type == LocationCoordinateType.RELATIVE ? origin + this.value : this.value;
+ }
+
+ /**
+ * Returns the coordinate type.
+ *
+ * @return coordinate type
+ */
+ @NonNull LocationCoordinateType type() {
+ return this.type;
+ }
+
+ /**
+ * Returns the raw numeric offset or absolute value.
+ *
+ * @return value
+ */
+ double value() {
+ return this.value;
+ }
+}
diff --git a/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/LocationCoordinateType.java b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/LocationCoordinateType.java
new file mode 100644
index 00000000..9fd2d0ee
--- /dev/null
+++ b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/LocationCoordinateType.java
@@ -0,0 +1,40 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.minestom.parser.location;
+
+/**
+ * Type of location coordinates
+ *
+ * @since 2.0.0
+ */
+public enum LocationCoordinateType {
+ /**
+ * Absolute coordinate
+ */
+ ABSOLUTE,
+ /**
+ * Coordinate relative to the sender's position
+ */
+ RELATIVE,
+}
diff --git a/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/PosParser.java b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/PosParser.java
new file mode 100644
index 00000000..2c0c9190
--- /dev/null
+++ b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/PosParser.java
@@ -0,0 +1,202 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.minestom.parser.location;
+
+import java.util.List;
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Entity;
+import org.apiguardian.api.API;
+import org.incendo.cloud.caption.CaptionVariable;
+import org.incendo.cloud.component.CommandComponent;
+import org.incendo.cloud.context.CommandContext;
+import org.incendo.cloud.context.CommandInput;
+import org.incendo.cloud.exception.parsing.ParserException;
+import org.incendo.cloud.minestom.caption.MinestomCaptionKeys;
+import org.incendo.cloud.parser.ArgumentParseResult;
+import org.incendo.cloud.parser.ArgumentParser;
+import org.incendo.cloud.parser.ParserDescriptor;
+import org.incendo.cloud.suggestion.BlockingSuggestionProvider;
+import org.jspecify.annotations.NonNull;
+
+/**
+ * Parser for {@link Pos} - parses the x, y, and z components (+ pitch/yaw components optionally).
+ *
+ * Can be absolute components (e.g. {@code 100}) or relative (e.g. {@code ~} or {@code ~5}).
+ *
+ * Non-entity senders using relative components will default to {@code 0}.
+ *
+ * @param command sender type
+ */
+public final class PosParser implements ArgumentParser, BlockingSuggestionProvider.Strings {
+
+ /**
+ * Creates a new pos parser that does not require yaw and pitch,
+ * defaulting them to {@code 0} when absent.
+ *
+ * @param command sender type
+ * @return the created parser
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static @NonNull ParserDescriptor posParser() {
+ return posParser(false);
+ }
+
+ /**
+ * Creates a new pos parser.
+ *
+ * @param requireRotation whether yaw and pitch tokens are required; if {@code false} they default to {@code 0}
+ * @param command sender type
+ * @return the created parser
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static @NonNull ParserDescriptor posParser(final boolean requireRotation) {
+ return ParserDescriptor.of(new PosParser<>(requireRotation), Pos.class);
+ }
+
+ /**
+ * Returns a {@link CommandComponent.Builder} using {@link #posParser()} as the parser.
+ *
+ * @param the command sender type
+ * @return the component builder
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static CommandComponent.@NonNull Builder posComponent() {
+ return CommandComponent.builder().parser(posParser());
+ }
+
+ private final LocationCoordinate coordinateParser = LocationCoordinate.of(LocationCoordinateType.ABSOLUTE, 0);
+ private final boolean requireRotation;
+
+ /**
+ * Creates a new pos parser.
+ *
+ * @param requireRotation whether yaw and pitch tokens are required
+ */
+ public PosParser(final boolean requireRotation) {
+ this.requireRotation = requireRotation;
+ }
+
+ @Override
+ public @NonNull ArgumentParseResult<@NonNull Pos> parse(
+ final @NonNull CommandContext<@NonNull C> commandContext,
+ final @NonNull CommandInput commandInput
+ ) {
+ final int required = this.requireRotation ? 5 : 3;
+ if (commandInput.remainingTokens() < required) {
+ return ArgumentParseResult.failure(
+ new PosParseException(commandInput.remainingInput(), commandContext)
+ );
+ }
+
+ @SuppressWarnings("unchecked")
+ final LocationCoordinate[] components = (LocationCoordinate[]) new LocationCoordinate>[required];
+ for (int i = 0; i < required; i++) {
+ if (commandInput.peekString().isEmpty()) {
+ return ArgumentParseResult.failure(
+ new PosParseException(commandInput.remainingInput(), commandContext)
+ );
+ }
+ final ArgumentParseResult> result =
+ this.coordinateParser.parse(commandContext, commandInput);
+ if (result.failure().isPresent()) {
+ return ArgumentParseResult.failure(result.failure().get());
+ }
+ components[i] = result.parsedValue().orElseThrow(NullPointerException::new);
+ }
+
+ final Pos origin = this.resolveOrigin(commandContext);
+
+ if (!this.requireRotation) {
+ return ArgumentParseResult.success(new Pos(
+ components[0].resolve(origin.x()),
+ components[1].resolve(origin.y()),
+ components[2].resolve(origin.z())
+ ));
+ }
+
+ return ArgumentParseResult.success(new Pos(
+ components[0].resolve(origin.x()),
+ components[1].resolve(origin.y()),
+ components[2].resolve(origin.z()),
+ (float) components[3].resolve(origin.yaw()),
+ (float) components[4].resolve(origin.pitch())
+ ));
+ }
+
+ private @NonNull Pos resolveOrigin(final @NonNull CommandContext commandContext) {
+ final Object sender = commandContext.sender();
+ if (sender instanceof CommandSender && sender instanceof Entity entity) {
+ return entity.getPosition();
+ }
+ return Pos.ZERO;
+ }
+
+ @Override
+ public @NonNull Iterable<@NonNull String> stringSuggestions(
+ final @NonNull CommandContext commandContext,
+ final @NonNull CommandInput input
+ ) {
+ return this.requireRotation ? List.of("~ ~ ~ ~ ~", "0 0 0 0 0") : List.of("~ ~ ~", "0 0 0");
+ }
+
+ /**
+ * Exception thrown when a {@link Pos} cannot be parsed from the input provided.
+ */
+ public static final class PosParseException extends ParserException {
+
+ private final String input;
+
+ /**
+ * Create a new pos parse exception.
+ *
+ * @param input string input
+ * @param context command context
+ */
+ public PosParseException(
+ final @NonNull String input,
+ final @NonNull CommandContext> context
+ ) {
+ super(
+ PosParser.class,
+ context,
+ MinestomCaptionKeys.ARGUMENT_PARSE_FAILURE_POS,
+ CaptionVariable.of("input", input)
+ );
+ this.input = input;
+ }
+
+ /**
+ * Returns the supplied input.
+ *
+ * @return input value
+ */
+ public @NonNull String input() {
+ return this.input;
+ }
+ }
+}
diff --git a/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/VecParser.java b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/VecParser.java
new file mode 100644
index 00000000..b82001a9
--- /dev/null
+++ b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/VecParser.java
@@ -0,0 +1,167 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.minestom.parser.location;
+
+import java.util.List;
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.entity.Entity;
+import org.apiguardian.api.API;
+import org.incendo.cloud.caption.CaptionVariable;
+import org.incendo.cloud.component.CommandComponent;
+import org.incendo.cloud.context.CommandContext;
+import org.incendo.cloud.context.CommandInput;
+import org.incendo.cloud.exception.parsing.ParserException;
+import org.incendo.cloud.minestom.caption.MinestomCaptionKeys;
+import org.incendo.cloud.parser.ArgumentParseResult;
+import org.incendo.cloud.parser.ArgumentParser;
+import org.incendo.cloud.parser.ParserDescriptor;
+import org.incendo.cloud.suggestion.BlockingSuggestionProvider;
+import org.jspecify.annotations.NonNull;
+
+/**
+ * Parser for {@link Vec} - parses the x, y, and z components.
+ *
+ * Can be absolute components (e.g. {@code 100}) or relative (e.g. {@code ~} or {@code ~5}).
+ *
+ * Non-entity senders using relative components will default to {@code 0}.
+ *
+ * @param command sender type
+ */
+public final class VecParser implements ArgumentParser, BlockingSuggestionProvider.Strings {
+
+ /**
+ * Creates a new vec parser.
+ *
+ * @param command sender type
+ * @return the created parser
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static @NonNull ParserDescriptor vecParser() {
+ return ParserDescriptor.of(new VecParser<>(), Vec.class);
+ }
+
+ /**
+ * Returns a {@link CommandComponent.Builder} using {@link #vecParser()} as the parser.
+ *
+ * @param the command sender type
+ * @return the component builder
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static CommandComponent.@NonNull Builder vecComponent() {
+ return CommandComponent.builder().parser(vecParser());
+ }
+
+ private final LocationCoordinate coordinateParser = LocationCoordinate.of(LocationCoordinateType.ABSOLUTE, 0);
+
+ @Override
+ public @NonNull ArgumentParseResult<@NonNull Vec> parse(
+ final @NonNull CommandContext<@NonNull C> commandContext,
+ final @NonNull CommandInput commandInput
+ ) {
+ if (commandInput.remainingTokens() < 3) {
+ return ArgumentParseResult.failure(
+ new VecParseException(commandInput.remainingInput(), commandContext)
+ );
+ }
+
+ @SuppressWarnings("unchecked")
+ final LocationCoordinate[] components = (LocationCoordinate[]) new LocationCoordinate>[3];
+ for (int i = 0; i < 3; i++) {
+ if (commandInput.peekString().isEmpty()) {
+ return ArgumentParseResult.failure(
+ new VecParseException(commandInput.remainingInput(), commandContext)
+ );
+ }
+ final ArgumentParseResult> result =
+ this.coordinateParser.parse(commandContext, commandInput);
+ if (result.failure().isPresent()) {
+ return ArgumentParseResult.failure(result.failure().get());
+ }
+ components[i] = result.parsedValue().orElseThrow(NullPointerException::new);
+ }
+
+ final Vec origin = this.resolveOrigin(commandContext);
+ return ArgumentParseResult.success(new Vec(
+ components[0].resolve(origin.x()),
+ components[1].resolve(origin.y()),
+ components[2].resolve(origin.z())
+ ));
+ }
+
+ private @NonNull Vec resolveOrigin(final @NonNull CommandContext commandContext) {
+ final Object sender = commandContext.sender();
+ if (sender instanceof CommandSender && sender instanceof Entity entity) {
+ final var pos = entity.getPosition();
+ return new Vec(pos.x(), pos.y(), pos.z());
+ }
+ return Vec.ZERO;
+ }
+
+ @Override
+ public @NonNull Iterable<@NonNull String> stringSuggestions(
+ final @NonNull CommandContext commandContext,
+ final @NonNull CommandInput input
+ ) {
+ return List.of("~ ~ ~", "0 0 0");
+ }
+
+ /**
+ * Exception thrown when a {@link Vec} cannot be parsed from the input provided.
+ */
+ public static final class VecParseException extends ParserException {
+
+ private final String input;
+
+ /**
+ * Create a new vec parse exception.
+ *
+ * @param input string input
+ * @param context command context
+ */
+ public VecParseException(
+ final @NonNull String input,
+ final @NonNull CommandContext> context
+ ) {
+ super(
+ VecParser.class,
+ context,
+ MinestomCaptionKeys.ARGUMENT_PARSE_FAILURE_VEC,
+ CaptionVariable.of("input", input)
+ );
+ this.input = input;
+ }
+
+ /**
+ * Returns the supplied input.
+ *
+ * @return input value
+ */
+ public @NonNull String input() {
+ return this.input;
+ }
+ }
+}
diff --git a/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/package-info.java b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/package-info.java
new file mode 100644
index 00000000..5246c5db
--- /dev/null
+++ b/cloud-minestom/src/main/java/org/incendo/cloud/minestom/parser/location/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * cloud-bukkit location-specific command arguments
+ */
+package org.incendo.cloud.minestom.parser.location;
diff --git a/gradle.properties b/gradle.properties
index d24f8afe..6672ced7 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
group=org.incendo
-version=2.0.0-SNAPSHOT
+version=2.1.0-SNAPSHOT
description=Integrations between Minecraft and Cloud Command Framework
org.gradle.caching=true