From 1c1fd49b5278e09f6251668941dba462120ab76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=C5=81uczak?= Date: Wed, 17 Jun 2026 16:15:13 +0200 Subject: [PATCH 1/4] GH-679: add jakarta validation suggestions filter --- examples/bukkit-jakarta/build.gradle.kts | 60 +++++ .../jakarta/ExampleBukkitJakartaPlugin.java | 32 +++ .../jakarta/command/JakartaCommand.java | 207 ++++++++++++++++++ .../annotations/MethodCommandExecutor.java | 1 + .../command/executor/CommandExecutor.java | 12 +- .../suggestion/SuggestionResult.java | 5 + .../suggestion/SuggestionService.java | 10 +- .../event/SuggestionResultEvent.java | 48 ++++ .../jakarta/JakartaMethodValidator.java | 15 +- .../jakarta/JakartaSuggestionListener.java | 122 +++++++++++ .../jakarta/LiteJakartaExtension.java | 7 +- .../jakarta/JakartaSuggestionTest.java | 55 +++++ .../litecommands/unit/AssertSuggest.java | 7 + settings.gradle.kts | 1 + 14 files changed, 565 insertions(+), 17 deletions(-) create mode 100644 examples/bukkit-jakarta/build.gradle.kts create mode 100644 examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/ExampleBukkitJakartaPlugin.java create mode 100644 examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java create mode 100644 litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java create mode 100644 litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java create mode 100644 litecommands-jakarta/test/dev/rollczi/litecommands/jakarta/JakartaSuggestionTest.java diff --git a/examples/bukkit-jakarta/build.gradle.kts b/examples/bukkit-jakarta/build.gradle.kts new file mode 100644 index 000000000..30ac335d4 --- /dev/null +++ b/examples/bukkit-jakarta/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("java") + id("com.gradleup.shadow") version "9.3.1" + id("net.minecrell.plugin-yml.bukkit") version "0.6.0" + id("xyz.jpenilla.run-paper") version "3.0.2" +} + +version = "3.10.9" + +repositories { + mavenCentral() + maven { url = uri("https://repo.panda-lang.org/releases/") } + maven { url = uri("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") } +} + +dependencies { + compileOnly("org.spigotmc:spigot-api:1.20.2-R0.1-SNAPSHOT") + + // implementation("dev.rollczi:litecommands-bukkit:3.10.9") // <-- uncomment in your project + // implementation("dev.rollczi:litecommands-jakarta:3.10.9") // <-- uncomment in your project + implementation(project(":litecommands-bukkit")) // don't use this line in your build.gradle + implementation(project(":litecommands-jakarta")) // don't use this line in your build.gradle +} + +val pluginName = "ExampleBukkitJakartaPlugin" +val packageName = "dev.rollczi.example.bukkit.jakarta" + +bukkit { + main = "$packageName.$pluginName" + apiVersion = "1.13" + author = "Rollczi" + name = pluginName + version = "${project.version}" +} + +tasks.shadowJar { + archiveFileName.set("$pluginName v${project.version}.jar") + + listOf( + "dev.rollczi.litecommands", + ).forEach { relocate(it, "$packageName.libs.$it") } +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +tasks.withType { + options.compilerArgs.add("-parameters") + options.release = 21 +} + +sourceSets.test { + java.setSrcDirs(emptyList()) + resources.setSrcDirs(emptyList()) +} + +tasks.runServer { + minecraftVersion("1.21.4") +} diff --git a/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/ExampleBukkitJakartaPlugin.java b/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/ExampleBukkitJakartaPlugin.java new file mode 100644 index 000000000..b2f47ed2d --- /dev/null +++ b/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/ExampleBukkitJakartaPlugin.java @@ -0,0 +1,32 @@ +package dev.rollczi.example.bukkit.jakarta; + +import dev.rollczi.example.bukkit.jakarta.command.JakartaCommand; +import dev.rollczi.litecommands.LiteCommands; +import dev.rollczi.litecommands.bukkit.LiteBukkitFactory; +import dev.rollczi.litecommands.jakarta.LiteJakartaExtension; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; + +public class ExampleBukkitJakartaPlugin extends JavaPlugin { + + private LiteCommands liteCommands; + + @Override + public void onEnable() { + this.liteCommands = LiteBukkitFactory.builder("my-jakarta-plugin") + .extension(new LiteJakartaExtension<>()) + .argument(JakartaCommand.User.class, new JakartaCommand.UserArgumentResolver()) + .commands( + new JakartaCommand() + ) + .build(); + } + + @Override + public void onDisable() { + if (this.liteCommands != null) { + this.liteCommands.unregister(); + } + } + +} diff --git a/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java b/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java new file mode 100644 index 000000000..ac1250d95 --- /dev/null +++ b/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java @@ -0,0 +1,207 @@ +package dev.rollczi.example.bukkit.jakarta.command; + +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.argument.parser.ParseResult; +import dev.rollczi.litecommands.argument.resolver.ArgumentResolver; +import dev.rollczi.litecommands.invocation.Invocation; +import dev.rollczi.litecommands.suggestion.SuggestionContext; +import dev.rollczi.litecommands.suggestion.SuggestionResult; +import org.bukkit.command.CommandSender; +import jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertFalse; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Negative; +import jakarta.validation.constraints.NegativeOrZero; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Instant; +import java.util.List; + +@Command(name = "jakarta") +public class JakartaCommand { + + @Execute(name = "age") + String age(@Min(1) @Max(120) @Arg int age) { + return "Your age is " + age; + } + + @Execute(name = "name") + String name(@NotBlank @Size(min = 3, max = 16) @Arg String name) { + return "Your name is " + name; + } + + @Execute(name = "boolean-true") + String booleanTrue(@AssertTrue @Arg boolean value) { + return "Value is " + value; + } + + @Execute(name = "boolean-false") + String booleanFalse(@AssertFalse @Arg boolean value) { + return "Value is " + value; + } + + @Execute(name = "positive") + String positive(@Positive @Arg int value) { + return "Positive value: " + value; + } + + @Execute(name = "positive-or-zero") + String positiveOrZero(@PositiveOrZero @Arg int value) { + return "Positive or zero value: " + value; + } + + @Execute(name = "negative") + String negative(@Negative @Arg int value) { + return "Negative value: " + value; + } + + @Execute(name = "negative-or-zero") + String negativeOrZero(@NegativeOrZero @Arg int value) { + return "Negative or zero value: " + value; + } + + @Execute(name = "digits") + String digits(@Digits(integer = 3, fraction = 2) @Arg double value) { + return "Digits: " + value; + } + + @Execute(name = "decimal") + String decimal(@DecimalMin("0.1") @DecimalMax("1.0") @Arg double value) { + return "Decimal value: " + value; + } + + @Execute(name = "past") + String past(@Past @Arg Instant instant) { + return "Past instant: " + instant; + } + + @Execute(name = "past-or-present") + String pastOrPresent(@PastOrPresent @Arg Instant instant) { + return "Past or present instant: " + instant; + } + + @Execute(name = "future") + String future(@Future @Arg Instant instant) { + return "Future instant: " + instant; + } + + @Execute(name = "future-or-present") + String futureOrPresent(@FutureOrPresent @Arg Instant instant) { + return "Future or present instant: " + instant; + } + + @Execute(name = "pattern") + String pattern(@Pattern(regexp = "[a-z]+") @Arg String text) { + return "Pattern match: " + text; + } + + @Execute(name = "email") + String email(@Email @Arg String email) { + return "Email: " + email; + } + + @Execute(name = "list") + String list(@NotEmpty @Size(min = 2) @Arg List list) { + return "List size: " + list.size(); + } + + @Execute(name = "user-info") + String userInfo(@NotBlank @Size(min = 3) @Arg String name, @Min(1) @Max(100) @Arg int level) { + return "User: " + name + ", Level: " + level; + } + + @Execute(name = "transfer") + String transfer(@NotBlank @Arg String from, @NotBlank @Arg String to, @Positive @Arg double amount) { + return "Transferred " + amount + " from " + from + " to " + to; + } + + @Execute(name = "user") + String user(@Valid @Arg User user) { + return "User: " + user.name + " (" + user.age + ")"; + } + + public record User(@NotBlank String name, @Min(18) int age) { + + public User(String name, int age) { + this.name = name; + this.age = age; + } + } + + public static class UserArgumentResolver extends ArgumentResolver { + + @Override + protected ParseResult parse(Invocation invocation, Argument context, String argument) { + String[] split = argument.split(":", 2); + + if (split.length < 2) { + return ParseResult.failure("Invalid user format. Use name:age"); + } + + try { + String name = split[0]; + int age = Integer.parseInt(split[1]); + + return ParseResult.success(new User(name, age)); + } catch (NumberFormatException e) { + return ParseResult.failure("Age must be a number."); + } + } + + @Override + public SuggestionResult suggest(Invocation invocation, Argument argument, SuggestionContext context) { + return SuggestionResult.of("name:18"); + } + + } + + // Custom annotations and custom validators + @Execute(name = "custom") + private String custom(@NotAdmin @Arg String name) { + return "Hello, " + name; + } + + @Target({ ElementType.PARAMETER, ElementType.FIELD }) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = NotAdminValidator.class) + @Documented + private @interface NotAdmin { + String message() default "Name cannot be 'admin'"; + Class[] groups() default {}; + Class[] payload() default {}; + } + + private static class NotAdminValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value == null || !value.equalsIgnoreCase("admin"); + } + } + +} diff --git a/litecommands-annotations/src/dev/rollczi/litecommands/annotations/MethodCommandExecutor.java b/litecommands-annotations/src/dev/rollczi/litecommands/annotations/MethodCommandExecutor.java index 2663e17f8..47a567c5e 100644 --- a/litecommands-annotations/src/dev/rollczi/litecommands/annotations/MethodCommandExecutor.java +++ b/litecommands-annotations/src/dev/rollczi/litecommands/annotations/MethodCommandExecutor.java @@ -46,6 +46,7 @@ public class MethodCommandExecutor extends AbstractCommandExecutor extends Scopeable, CommandNode, Prioritized { @@ -32,6 +32,10 @@ public interface CommandExecutor extends Scopeable, CommandNode, CommandExecutorMatchResult match(RequirementsResult result); + default Object getInstance() { + return null; + } + @Unmodifiable @Override default Collection names() { diff --git a/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionResult.java b/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionResult.java index 6c98fe26e..23d58a169 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionResult.java +++ b/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionResult.java @@ -10,6 +10,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; public class SuggestionResult { @@ -38,6 +39,10 @@ public void clear() { this.suggestions.clear(); } + public void removeIf(Predicate predicate) { + this.suggestions.removeIf(predicate); + } + public void addAll(SuggestionResult result) { this.suggestions.addAll(result.suggestions); } diff --git a/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java b/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java index d32c4504b..3d6ee87b3 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java +++ b/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java @@ -2,18 +2,18 @@ import dev.rollczi.litecommands.argument.Argument; import dev.rollczi.litecommands.argument.parser.Parser; +import dev.rollczi.litecommands.argument.parser.ParserRegistry; import dev.rollczi.litecommands.argument.suggester.Suggester; import dev.rollczi.litecommands.argument.suggester.SuggesterRegistry; import dev.rollczi.litecommands.argument.suggester.input.SuggestionInputMatcher; import dev.rollczi.litecommands.argument.suggester.input.SuggestionInputResult; -import dev.rollczi.litecommands.argument.parser.ParserRegistry; -import dev.rollczi.litecommands.command.executor.CommandExecutor; import dev.rollczi.litecommands.command.CommandRoute; +import dev.rollczi.litecommands.command.executor.CommandExecutor; import dev.rollczi.litecommands.event.EventPublisher; -import dev.rollczi.litecommands.flow.Flow; import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.suggestion.event.SuggestionCommandRouteEvent; import dev.rollczi.litecommands.suggestion.event.SuggestionExecutorEvent; +import dev.rollczi.litecommands.suggestion.event.SuggestionResultEvent; import dev.rollczi.litecommands.util.StringUtil; import dev.rollczi.litecommands.validator.ValidatorService; @@ -123,7 +123,7 @@ public > SuggestionResult sugges SuggestionResult collector = SuggestionResult.empty(); for (Argument argument : executor.getArguments()) { - SuggestionInputResult result = suggestArgument(invocation, matcher, argument); + SuggestionInputResult result = suggestArgument(invocation, executor, matcher, argument); collector.addAll(result.getResult()); switch (result.getCause()) { @@ -138,6 +138,7 @@ public > SuggestionResult sugges private > SuggestionInputResult suggestArgument( Invocation invocation, + CommandExecutor executor, MATCHER matcher, Argument argument ) { @@ -146,6 +147,7 @@ private > SuggestionInpu Suggester suggester = suggesterRegistry.getSuggester(parsedType, argument.getKey()); SuggestionInputResult result = matcher.nextArgument(invocation, argument, parser, suggester); + this.publisher.publish(new SuggestionResultEvent(invocation, executor, argument, parser, result.getResult())); if (result.isEnd() && matcher.isOptionalArgument(invocation, argument, parser)) { return SuggestionInputResult.continueWith(result); diff --git a/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java b/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java new file mode 100644 index 000000000..ddae03b6b --- /dev/null +++ b/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java @@ -0,0 +1,48 @@ +package dev.rollczi.litecommands.suggestion.event; + +import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.argument.parser.Parser; +import dev.rollczi.litecommands.command.executor.CommandExecutor; +import dev.rollczi.litecommands.event.Event; +import dev.rollczi.litecommands.invocation.Invocation; +import dev.rollczi.litecommands.suggestion.SuggestionResult; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +public class SuggestionResultEvent implements Event { + + private final Invocation invocation; + private final CommandExecutor executor; + private final Argument argument; + private final Parser parser; + private final SuggestionResult result; + + public SuggestionResultEvent(Invocation invocation, CommandExecutor executor, Argument argument, Parser parser, SuggestionResult result) { + this.invocation = invocation; + this.executor = executor; + this.argument = argument; + this.parser = parser; + this.result = result; + } + + public Invocation getInvocation() { + return invocation; + } + + public CommandExecutor getExecutor() { + return executor; + } + + public Argument getArgument() { + return argument; + } + + public Parser getParser() { + return parser; + } + + public SuggestionResult getResult() { + return result; + } + +} diff --git a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaMethodValidator.java b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaMethodValidator.java index 969a9c689..a675e6762 100644 --- a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaMethodValidator.java +++ b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaMethodValidator.java @@ -5,11 +5,10 @@ import dev.rollczi.litecommands.validator.ValidatorResult; import jakarta.validation.ConstraintViolation; import jakarta.validation.ElementKind; +import jakarta.validation.Path; import jakarta.validation.Validator; import jakarta.validation.executable.ExecutableValidator; -import jakarta.validation.Path; -import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -34,18 +33,18 @@ public ValidatorResult validate(MethodValidatorContext context) { List violationsList = violations.stream() .map(objectConstraintViolation -> { Path propertyPath = objectConstraintViolation.getPropertyPath(); - Path.Node leafNode = null; + Path.ParameterNode parameterNode = null; for (Path.Node node : propertyPath) { - leafNode = node; + if (node.getKind() == ElementKind.PARAMETER) { + parameterNode = node.as(Path.ParameterNode.class); + } } - if (leafNode == null || leafNode.getKind() != ElementKind.PARAMETER) { - throw new IllegalStateException("Invalid leaf node: " + (leafNode == null ? "null" : leafNode.getKind())); + if (parameterNode == null) { + throw new IllegalStateException("No parameter node found in path: " + propertyPath); } - Path.ParameterNode parameterNode = leafNode.as(Path.ParameterNode.class); - return new JakartaRawResult.Entry(objectConstraintViolation, context.getDefinition().getRequirement(parameterNode.getParameterIndex())); }).collect(Collectors.toList()); diff --git a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java new file mode 100644 index 000000000..0eaf0181c --- /dev/null +++ b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java @@ -0,0 +1,122 @@ +package dev.rollczi.litecommands.jakarta; + +import dev.rollczi.litecommands.annotations.meta.MetaAnnotationKeys; +import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.suggestion.event.SuggestionResultEvent; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ElementKind; +import jakarta.validation.Path; +import jakarta.validation.Validator; + +import java.lang.reflect.Executable; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Set; +import java.util.function.Consumer; +import java.util.logging.Logger; + +class JakartaSuggestionListener implements Consumer { + + private static final Logger LOGGER = Logger.getLogger("LiteCommands"); + + private final Validator validator; + + JakartaSuggestionListener(Validator validator) { + this.validator = validator; + } + + @Override + public void accept(SuggestionResultEvent event) { + Argument argument = event.getArgument(); + Parameter parameter = argument.meta().get(MetaAnnotationKeys.SOURCE_PARAMETER); + + Executable executable = parameter.getDeclaringExecutable(); + if (!(executable instanceof Method method)) { + return; + } + + int parameterIndex = getParameterIndex(parameter); + if (parameterIndex == -1) { + return; + } + + Object handle = event.getExecutor().getInstance(); + event.getResult().removeIf(suggestion -> { + Object parsedValue = parse(suggestion.multilevel(), argument.getType().getRawType()); + if (parsedValue == null) { + return false; + } + + Object[] parameters = new Object[method.getParameterCount()]; + Class[] parameterTypes = method.getParameterTypes(); + + for (int i = 0; i < parameters.length; i++) { + parameters[i] = i == parameterIndex ? parsedValue : defaultValue(parameterTypes[i]); + } + + try { + Set> violations = validator.forExecutables().validateParameters(handle, method, parameters); + + for (ConstraintViolation violation : violations) { + for (Path.Node node : violation.getPropertyPath()) { + if (node.getKind() != ElementKind.PARAMETER) { + continue; + } + + Path.ParameterNode parameterNode = node.as(Path.ParameterNode.class); + + if (parameterNode.getParameterIndex() == parameterIndex) { + return true; + } + } + } + + return false; + } catch (IllegalArgumentException e) { + LOGGER.warning(e.getMessage()); + return false; + } + }); + } + + private int getParameterIndex(Parameter parameter) { + Parameter[] parameters = parameter.getDeclaringExecutable().getParameters(); + for (int i = 0; i < parameters.length; i++) { + if (parameters[i].equals(parameter)) { + return i; + } + } + return -1; + } + + private Object defaultValue(Class type) { + if (type.isPrimitive()) { + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0f; + if (type == double.class) return 0.0d; + } + + return null; + } + + private Object parse(String value, Class type) { + try { + if (type == int.class || type == Integer.class) return Integer.parseInt(value); + if (type == long.class || type == Long.class) return Long.parseLong(value); + if (type == double.class || type == Double.class) return Double.parseDouble(value); + if (type == float.class || type == Float.class) return Float.parseFloat(value); + if (type == short.class || type == Short.class) return Short.parseShort(value); + if (type == byte.class || type == Byte.class) return Byte.parseByte(value); + if (type == String.class) return value; + } catch (NumberFormatException e) { + return null; + } + return null; + } + +} diff --git a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/LiteJakartaExtension.java b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/LiteJakartaExtension.java index 813d7096a..76f4e7af9 100644 --- a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/LiteJakartaExtension.java +++ b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/LiteJakartaExtension.java @@ -4,6 +4,8 @@ import dev.rollczi.litecommands.LiteCommandsInternal; import dev.rollczi.litecommands.configurator.LiteConfigurator; import dev.rollczi.litecommands.extension.LiteExtension; +import dev.rollczi.litecommands.suggestion.event.SuggestionResultEvent; +import jakarta.validation.Validator; public class LiteJakartaExtension implements LiteExtension> { @@ -16,10 +18,13 @@ public void configure(LiteConfigurator> configurer) { @Override public void extend(LiteCommandsBuilder builder, LiteCommandsInternal internal) { + Validator validator = settings.getOrCreateValidatorFactory().getValidator(); + builder .result(JakartaRawResult.class, new JakartaRawResultHandler<>(settings, internal.getSchematicGenerator())) .annotations(configuration -> configuration - .methodValidator(new JakartaMethodValidator<>(settings.getOrCreateValidatorFactory().getValidator()))); + .methodValidator(new JakartaMethodValidator<>(validator))) + .listener(SuggestionResultEvent.class, new JakartaSuggestionListener(validator)); } } diff --git a/litecommands-jakarta/test/dev/rollczi/litecommands/jakarta/JakartaSuggestionTest.java b/litecommands-jakarta/test/dev/rollczi/litecommands/jakarta/JakartaSuggestionTest.java new file mode 100644 index 000000000..7af01c1e1 --- /dev/null +++ b/litecommands-jakarta/test/dev/rollczi/litecommands/jakarta/JakartaSuggestionTest.java @@ -0,0 +1,55 @@ +package dev.rollczi.litecommands.jakarta; + +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.unit.LiteCommandsTestFactory; +import dev.rollczi.litecommands.unit.TestPlatform; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.junit.jupiter.api.Test; + +class JakartaSuggestionTest { + + @Command(name = "test") + static class TestCommand { + @Execute(name = "min") + public void min(@Min(1) @Arg int number) {} + + @Execute(name = "max") + public void max(@Max(10) @Arg int number) {} + } + + @Command(name = "unrelated") + static class UnrelatedTestCommand { + @Execute(name = "test") + public void test(@Min(5) @Arg int other, @Min(5) @Max(10) @Arg int target) {} + } + + TestPlatform platform = LiteCommandsTestFactory.startPlatform(config -> config + .extension(new LiteJakartaExtension<>()) + .commands(new TestCommand(), new UnrelatedTestCommand()) + ); + + @Test + void testMinSuggestion() { + platform.suggest("test min ") + .assertNotEmpty() + .assertNotSuggest("0"); + } + + @Test + void testMaxSuggestion() { + platform.suggest("test max ") + .assertNotEmpty() + .assertNotSuggest("11", "50", "100", "500"); + } + + @Test + void testTargetSuggestionWithUnrelatedMinConstraint() { + platform.suggest("unrelated test 0 ") + .assertSuggestAndFlush("5", "6", "7", "8", "9", "10") + .assertNotSuggest("4", "11"); + } + +} diff --git a/litecommands-unit/src/dev/rollczi/litecommands/unit/AssertSuggest.java b/litecommands-unit/src/dev/rollczi/litecommands/unit/AssertSuggest.java index 9011a1b7b..9f447b96f 100644 --- a/litecommands-unit/src/dev/rollczi/litecommands/unit/AssertSuggest.java +++ b/litecommands-unit/src/dev/rollczi/litecommands/unit/AssertSuggest.java @@ -151,4 +151,11 @@ public AssertSuggest assertEmpty() { return this; } + public AssertSuggest assertNotSuggest(String... suggestions) { + assertThat(suggest.getSuggestions()) + .map(Suggestion::multilevel) + .doesNotContain(suggestions); + + return this; + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index b8815a5d6..ed8d31c8d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,6 +41,7 @@ include(":litecommands-telegrambots", tests = false) include(":examples:bukkit", tests = false) include(":examples:bukkit-adventure-platform", tests = false) include(":examples:bukkit-chatgpt", tests = false) +include(":examples:bukkit-jakarta", tests = false) include(":examples:minestom", toVersion("25"), tests = false) include(":examples:paper", VERSION_21, tests = false) include(":examples:fabric", tests = false) From 078b46a68879472d074eb25efb073fabb88b0e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=C5=81uczak?= Date: Wed, 17 Jun 2026 17:11:25 +0200 Subject: [PATCH 2/4] GH-679: add jakarta validation suggestions filter - refactor --- .../jakarta/command/JakartaCommand.java | 1 - .../command/executor/CommandExecutor.java | 2 +- .../jakarta/JakartaSuggestionListener.java | 172 ++++++++++++------ 3 files changed, 122 insertions(+), 53 deletions(-) diff --git a/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java b/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java index ac1250d95..1b16aa1a4 100644 --- a/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java +++ b/examples/bukkit-jakarta/src/main/java/dev/rollczi/example/bukkit/jakarta/command/JakartaCommand.java @@ -181,7 +181,6 @@ public SuggestionResult suggest(Invocation invocation, Argument extends Scopeable, CommandNode, Prioritized { diff --git a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java index 0eaf0181c..2339229b8 100644 --- a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java +++ b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java @@ -2,17 +2,21 @@ import dev.rollczi.litecommands.annotations.meta.MetaAnnotationKeys; import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.suggestion.Suggestion; import dev.rollczi.litecommands.suggestion.event.SuggestionResultEvent; import jakarta.validation.ConstraintViolation; import jakarta.validation.ElementKind; import jakarta.validation.Path; import jakarta.validation.Validator; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Executable; import java.lang.reflect.Method; import java.lang.reflect.Parameter; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import java.util.logging.Logger; class JakartaSuggestionListener implements Consumer { @@ -41,42 +45,70 @@ public void accept(SuggestionResultEvent event) { } Object handle = event.getExecutor().getInstance(); - event.getResult().removeIf(suggestion -> { - Object parsedValue = parse(suggestion.multilevel(), argument.getType().getRawType()); - if (parsedValue == null) { - return false; - } - Object[] parameters = new Object[method.getParameterCount()]; - Class[] parameterTypes = method.getParameterTypes(); + event.getResult().removeIf(suggestion -> + !isValidSuggestion(suggestion, argument, method, handle, parameterIndex) + ); + } - for (int i = 0; i < parameters.length; i++) { - parameters[i] = i == parameterIndex ? parsedValue : defaultValue(parameterTypes[i]); - } + private boolean isValidSuggestion( + Suggestion suggestion, + Argument argument, + Method method, + Object handle, + int parameterIndex + ) { + Object parsedValue = Parser.parse( + suggestion.multilevel(), + argument.getType().getRawType() + ); + + if (parsedValue == null) { + return false; + } - try { - Set> violations = validator.forExecutables().validateParameters(handle, method, parameters); + Object[] parameters = buildParameters(method, parameterIndex, parsedValue); + + try { + Set> violations = + validator.forExecutables().validateParameters(handle, method, parameters); + + return !hasViolationForParameter(violations, parameterIndex); + + } catch (IllegalArgumentException e) { + LOGGER.warning(e.getMessage()); + return false; + } + } + + private Object[] buildParameters(Method method, int index, Object value) { + Class[] types = method.getParameterTypes(); + Object[] params = new Object[types.length]; + + for (int i = 0; i < types.length; i++) { + params[i] = (i == index) ? value : Defaults.get(types[i]); + } - for (ConstraintViolation violation : violations) { - for (Path.Node node : violation.getPropertyPath()) { - if (node.getKind() != ElementKind.PARAMETER) { - continue; - } + return params; + } - Path.ParameterNode parameterNode = node.as(Path.ParameterNode.class); + private boolean hasViolationForParameter( + Set> violations, + int parameterIndex + ) { + for (ConstraintViolation violation : violations) { + for (Path.Node node : violation.getPropertyPath()) { - if (parameterNode.getParameterIndex() == parameterIndex) { - return true; - } - } + if (node.getKind() != ElementKind.PARAMETER) { + continue; } - return false; - } catch (IllegalArgumentException e) { - LOGGER.warning(e.getMessage()); - return false; + if (((Path.ParameterNode) node).getParameterIndex() == parameterIndex) { + return true; + } } - }); + } + return false; } private int getParameterIndex(Parameter parameter) { @@ -89,34 +121,72 @@ private int getParameterIndex(Parameter parameter) { return -1; } - private Object defaultValue(Class type) { - if (type.isPrimitive()) { - if (type == boolean.class) return false; - if (type == char.class) return '\0'; - if (type == byte.class) return (byte) 0; - if (type == short.class) return (short) 0; - if (type == int.class) return 0; - if (type == long.class) return 0L; - if (type == float.class) return 0.0f; - if (type == double.class) return 0.0d; + private static final class Parser { + + private static final Map, Function> PARSERS = Map.of( + Byte.class, Byte::parseByte, + Short.class, Short::parseShort, + Integer.class, Integer::parseInt, + Long.class, Long::parseLong, + Float.class, Float::parseFloat, + Double.class, Double::parseDouble, + Boolean.class, Boolean::parseBoolean, + Character.class, s -> s.isEmpty() ? null : s.charAt(0), + String.class, Function.identity() + ); + + @SuppressWarnings("unchecked") + @Nullable + private static T parse(String value, Class type) { + Function parser = PARSERS.get(normalize(type)); + + if (parser == null) { + return null; + } + + try { + return (T) parser.apply(value); + } catch (RuntimeException e) { + return null; + } } - return null; - } + private static Class normalize(Class type) { + if (!type.isPrimitive()) { + return type; + } - private Object parse(String value, Class type) { - try { - if (type == int.class || type == Integer.class) return Integer.parseInt(value); - if (type == long.class || type == Long.class) return Long.parseLong(value); - if (type == double.class || type == Double.class) return Double.parseDouble(value); - if (type == float.class || type == Float.class) return Float.parseFloat(value); - if (type == short.class || type == Short.class) return Short.parseShort(value); - if (type == byte.class || type == Byte.class) return Byte.parseByte(value); - if (type == String.class) return value; - } catch (NumberFormatException e) { - return null; + // When java 21 comes, switch to type matching `(switch(type))` + return switch (type.getName()) { + case "byte" -> Byte.class; + case "short" -> Short.class; + case "int" -> Integer.class; + case "long" -> Long.class; + case "float" -> Float.class; + case "double" -> Double.class; + case "boolean" -> Boolean.class; + case "char" -> Character.class; + default -> type; + }; } - return null; } + private static final class Defaults { + + private static final Map, Object> DEFAULTS = Map.of( + boolean.class, false, + char.class, '\0', + byte.class, (byte) 0, + short.class, (short) 0, + int.class, 0, + long.class, 0L, + float.class, 0f, + double.class, 0d + ); + + @Nullable + private static Object get(Class type) { + return DEFAULTS.get(type); + } + } } From ef863dc2b0c80421cc397af6a796bae8c8a58baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=C5=81uczak?= Date: Fri, 19 Jun 2026 23:36:03 +0200 Subject: [PATCH 3/4] GH-679: refactor --- examples/bukkit-jakarta/build.gradle.kts | 6 +-- .../event/SuggestionResultEvent.java | 44 +++---------------- .../jakarta/JakartaSuggestionListener.java | 6 +-- 3 files changed, 13 insertions(+), 43 deletions(-) diff --git a/examples/bukkit-jakarta/build.gradle.kts b/examples/bukkit-jakarta/build.gradle.kts index 30ac335d4..c746552d0 100644 --- a/examples/bukkit-jakarta/build.gradle.kts +++ b/examples/bukkit-jakarta/build.gradle.kts @@ -5,7 +5,7 @@ plugins { id("xyz.jpenilla.run-paper") version "3.0.2" } -version = "3.10.9" +version = "3.11.0" repositories { mavenCentral() @@ -16,8 +16,8 @@ repositories { dependencies { compileOnly("org.spigotmc:spigot-api:1.20.2-R0.1-SNAPSHOT") - // implementation("dev.rollczi:litecommands-bukkit:3.10.9") // <-- uncomment in your project - // implementation("dev.rollczi:litecommands-jakarta:3.10.9") // <-- uncomment in your project + // implementation("dev.rollczi:litecommands-bukkit:3.11.0") // <-- uncomment in your project + // implementation("dev.rollczi:litecommands-jakarta:3.11.0") // <-- uncomment in your project implementation(project(":litecommands-bukkit")) // don't use this line in your build.gradle implementation(project(":litecommands-jakarta")) // don't use this line in your build.gradle } diff --git a/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java b/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java index ddae03b6b..7c5a6e47b 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java +++ b/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java @@ -9,40 +9,10 @@ import org.jetbrains.annotations.ApiStatus; @ApiStatus.Experimental -public class SuggestionResultEvent implements Event { - - private final Invocation invocation; - private final CommandExecutor executor; - private final Argument argument; - private final Parser parser; - private final SuggestionResult result; - - public SuggestionResultEvent(Invocation invocation, CommandExecutor executor, Argument argument, Parser parser, SuggestionResult result) { - this.invocation = invocation; - this.executor = executor; - this.argument = argument; - this.parser = parser; - this.result = result; - } - - public Invocation getInvocation() { - return invocation; - } - - public CommandExecutor getExecutor() { - return executor; - } - - public Argument getArgument() { - return argument; - } - - public Parser getParser() { - return parser; - } - - public SuggestionResult getResult() { - return result; - } - -} +public record SuggestionResultEvent( + Invocation invocation, + CommandExecutor executor, + Argument argument, + Parser parser, + SuggestionResult result +) implements Event { } diff --git a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java index 2339229b8..bd41d38dc 100644 --- a/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java +++ b/litecommands-jakarta/src/dev/rollczi/litecommands/jakarta/JakartaSuggestionListener.java @@ -31,7 +31,7 @@ class JakartaSuggestionListener implements Consumer { @Override public void accept(SuggestionResultEvent event) { - Argument argument = event.getArgument(); + Argument argument = event.argument(); Parameter parameter = argument.meta().get(MetaAnnotationKeys.SOURCE_PARAMETER); Executable executable = parameter.getDeclaringExecutable(); @@ -44,9 +44,9 @@ public void accept(SuggestionResultEvent event) { return; } - Object handle = event.getExecutor().getInstance(); + Object handle = event.executor().getInstance(); - event.getResult().removeIf(suggestion -> + event.result().removeIf(suggestion -> !isValidSuggestion(suggestion, argument, method, handle, parameterIndex) ); } From 3a3d5413b167cec89ea158a4a3c68c3581dede58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=C5=81uczak?= Date: Fri, 19 Jun 2026 23:40:14 +0200 Subject: [PATCH 4/4] GH-679: refactor --- .../rollczi/litecommands/suggestion/SuggestionService.java | 2 +- .../litecommands/suggestion/event/SuggestionResultEvent.java | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java b/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java index 3d6ee87b3..e90a14061 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java +++ b/litecommands-core/src/dev/rollczi/litecommands/suggestion/SuggestionService.java @@ -147,7 +147,7 @@ private > SuggestionInpu Suggester suggester = suggesterRegistry.getSuggester(parsedType, argument.getKey()); SuggestionInputResult result = matcher.nextArgument(invocation, argument, parser, suggester); - this.publisher.publish(new SuggestionResultEvent(invocation, executor, argument, parser, result.getResult())); + this.publisher.publish(new SuggestionResultEvent(executor, argument, result.getResult())); if (result.isEnd() && matcher.isOptionalArgument(invocation, argument, parser)) { return SuggestionInputResult.continueWith(result); diff --git a/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java b/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java index 7c5a6e47b..6f7e52196 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java +++ b/litecommands-core/src/dev/rollczi/litecommands/suggestion/event/SuggestionResultEvent.java @@ -1,18 +1,14 @@ package dev.rollczi.litecommands.suggestion.event; import dev.rollczi.litecommands.argument.Argument; -import dev.rollczi.litecommands.argument.parser.Parser; import dev.rollczi.litecommands.command.executor.CommandExecutor; import dev.rollczi.litecommands.event.Event; -import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.suggestion.SuggestionResult; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Experimental public record SuggestionResultEvent( - Invocation invocation, CommandExecutor executor, Argument argument, - Parser parser, SuggestionResult result ) implements Event { }