feat(annotations): add @CommandMethod annotation processing (#366)

We now verify the following at compile time:
- That `@CommandMethod` annotated methods are non-static (error)
- That `@CommandMethod` annotated methods are public (warning)
- That the `@CommandMethod` syntax and specified `@Argument`s match
- That no optional argument precedes a required argument
This commit is contained in:
Alexander Söderberg 2022-05-31 21:06:15 +02:00 committed by Jason
parent f1582fb64e
commit 74fd40f403
17 changed files with 500 additions and 14 deletions

View file

@ -5,4 +5,5 @@
<suppressions>
<suppress checks="(?:(?:Member|Method)Name|DesignForExtension|Javadoc.*)" files=".*[\\/]mixin[\\/].*"/>
<suppress checks="(?:Javadoc.*)" files=".*[\\/]bukkit[\\/]internal[\\/].*"/>
<suppress checks="(?:Javadoc.*)" files=".*[\\/]example-.*[\\/].*"/>
</suppressions>

View file

@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Core: Add delegating command execution handlers ([#363](https://github.com/Incendo/cloud/pull/363))
- Core: Add `builder()` getter to `Command.Builder` ([#363](https://github.com/Incendo/cloud/pull/363))
- Annotations: Annotation string processors ([#353](https://github.com/Incendo/cloud/pull/353))
- Annotations: `@CommandContainer` annotation processing
- Annotations: `@CommandContainer` annotation processing ([#364](https://github.com/Incendo/cloud/pull/364))
- Annotations: `@CommandMethod` annotation processing for compile-time validation ([#365](https://github.com/Incendo/cloud/pull/365))
### Fixed
- Core: Fix missing caption registration for the regex caption ([#351](https://github.com/Incendo/cloud/pull/351))

View file

@ -23,7 +23,12 @@
//
package cloud.commandframework.annotations;
enum ArgumentMode {
/**
* The mode of an argument.
* <p>
* Public since 1.7.0.
*/
public enum ArgumentMode {
LITERAL,
OPTIONAL,
REQUIRED

View file

@ -35,6 +35,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface CommandMethod {
String ANNOTATION_PATH = "cloud.commandframework.annotations.CommandMethod";
/**
* Command syntax

View file

@ -26,7 +26,10 @@ package cloud.commandframework.annotations;
import java.util.List;
import org.checkerframework.checker.nullness.qual.NonNull;
final class SyntaxFragment {
/**
* Public since 1.7.0.
*/
public final class SyntaxFragment {
private final String major;
private final List<String> minor;
@ -42,15 +45,34 @@ final class SyntaxFragment {
this.argumentMode = argumentMode;
}
@NonNull String getMajor() {
/**
* Returns the major portion of the fragment.
* <p>
* This is likely the name of an argument, or a string literal.
*
* @return the major part of the fragment
*/
public @NonNull String getMajor() {
return this.major;
}
@NonNull List<@NonNull String> getMinor() {
/**
* Returns the minor part of the fragment.
* <p>
* This is likely a list of aliases.
*
* @return the minor part of the fragment.
*/
public @NonNull List<@NonNull String> getMinor() {
return this.minor;
}
@NonNull ArgumentMode getArgumentMode() {
/**
* Returns the argument mode.
*
* @return the argument mode
*/
public @NonNull ArgumentMode getArgumentMode() {
return this.argumentMode;
}

View file

@ -33,9 +33,11 @@ import java.util.regex.Pattern;
import org.checkerframework.checker.nullness.qual.NonNull;
/**
* Parses command syntax into syntax fragments
* Parses command syntax into syntax fragments.
* <p>
* Public since 1.7.0.
*/
final class SyntaxParser implements Function<@NonNull String, @NonNull List<@NonNull SyntaxFragment>> {
public final class SyntaxParser implements Function<@NonNull String, @NonNull List<@NonNull SyntaxFragment>> {
private static final Predicate<String> PATTERN_ARGUMENT_LITERAL = Pattern.compile("([A-Za-z0-9\\-_]+)(|([A-Za-z0-9\\-_]+))*")
.asPredicate();
@ -72,5 +74,4 @@ final class SyntaxParser implements Function<@NonNull String, @NonNull List<@Non
}
return syntaxFragments;
}
}

View file

@ -0,0 +1,67 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// 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 cloud.commandframework.annotations.processing;
import cloud.commandframework.annotations.CommandMethod;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.TypeElement;
@SupportedAnnotationTypes(CommandMethod.ANNOTATION_PATH)
public final class CommandMethodProcessor extends AbstractProcessor {
@Override
public boolean process(
final Set<? extends TypeElement> annotations,
final RoundEnvironment roundEnv
) {
final Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(CommandMethod.class);
if (elements.isEmpty()) {
return false; // Nothing to process...
}
for (final Element element : elements) {
if (element.getKind() != ElementKind.METHOD) {
// @CommandMethod can also be used on classes, but there's
// essentially nothing to process there...
continue;
}
element.accept(new CommandMethodVisitor(this.processingEnv), null);
}
// https://errorprone.info/bugpattern/DoNotClaimAnnotations
return false;
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latest();
}
}

View file

@ -0,0 +1,191 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// 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 cloud.commandframework.annotations.processing;
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.ArgumentMode;
import cloud.commandframework.annotations.CommandMethod;
import cloud.commandframework.annotations.SyntaxFragment;
import cloud.commandframework.annotations.SyntaxParser;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementVisitor;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.TypeParameterElement;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
import org.checkerframework.checker.nullness.qual.NonNull;
class CommandMethodVisitor implements ElementVisitor<Void, Void> {
private final ProcessingEnvironment processingEnvironment;
private final SyntaxParser syntaxParser;
CommandMethodVisitor(final @NonNull ProcessingEnvironment processingEnvironment) {
this.processingEnvironment = processingEnvironment;
this.syntaxParser = new SyntaxParser();
}
@Override
public Void visit(final Element e) {
return this.visit(e, null);
}
@Override
public Void visit(final Element e, final Void unused) {
return null;
}
@Override
public Void visitPackage(final PackageElement e, final Void unused) {
return null;
}
@Override
public Void visitType(final TypeElement e, final Void unused) {
return null;
}
@Override
public Void visitVariable(final VariableElement e, final Void unused) {
return null;
}
@Override
public Void visitExecutable(final ExecutableElement e, final Void unused) {
if (!e.getModifiers().contains(Modifier.PUBLIC)) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.WARNING,
String.format(
"@CommandMethod annotated methods should be public (%s)",
e.getSimpleName()
),
e
);
}
if (e.getModifiers().contains(Modifier.STATIC)) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format(
"@CommandMethod annotated methods should be non-static (%s)",
e.getSimpleName()
),
e
);
}
if (e.getReturnType().toString().equals("Void")) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format(
"@CommandMethod annotated methods should return void (%s)",
e.getSimpleName()
),
e
);
}
final CommandMethod commandMethod = e.getAnnotation(CommandMethod.class);
final List<String> parameterArgumentNames = e.getParameters()
.stream()
.map(parameter -> parameter.getAnnotation(Argument.class))
.filter(Objects::nonNull)
.map(Argument::value)
.collect(Collectors.toList());
final List<String> parsedArgumentNames = new ArrayList<>(parameterArgumentNames.size());
final List<SyntaxFragment> syntaxFragments = this.syntaxParser.apply(commandMethod.value());
boolean foundOptional = false;
for (final SyntaxFragment fragment : syntaxFragments) {
if (fragment.getArgumentMode() == ArgumentMode.LITERAL) {
continue;
}
if (!parameterArgumentNames.contains(fragment.getMajor())) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format(
"@Argument(\"%s\") is missing from @CommandMethod (%s)",
fragment.getMajor(),
e.getSimpleName()
),
e
);
}
if (fragment.getArgumentMode() == ArgumentMode.REQUIRED) {
if (foundOptional) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format(
"Required argument '%s' cannot succeed an optional argument (%s)",
fragment.getMajor(),
e.getSimpleName()
),
e
);
}
} else {
foundOptional = true;
}
parsedArgumentNames.add(fragment.getMajor());
}
for (final String argument : parameterArgumentNames) {
if (!parsedArgumentNames.contains(argument)) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format(
"Argument '%s' is missing from the @CommandMethod syntax (%s)",
argument,
e.getSimpleName()
),
e
);
}
}
return null;
}
@Override
public Void visitTypeParameter(final TypeParameterElement e, final Void unused) {
return null;
}
@Override
public Void visitUnknown(final Element e, final Void unused) {
return null;
}
}

View file

@ -1 +1,2 @@
cloud.commandframework.annotations.processing.CommandContainerProcessor
cloud.commandframework.annotations.processing.CommandMethodProcessor

View file

@ -0,0 +1,124 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// 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 cloud.commandframework.annotations.processing;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import org.junit.jupiter.api.Test;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
public class CommandMethodProcessorTest {
@Test
void testValidCommandMethodParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandMethodProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandMethod.java")
);
// Assert
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void testStaticCommandMethodParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandMethodProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandMethodStatic.java")
);
// Assert
assertThat(compilation).failed();
assertThat(compilation).hadErrorContaining("@CommandMethod annotated methods should be non-static (commandMethod)");
}
@Test
void testOptionalBeforeRequiredParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandMethodProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandMethodOptionalBeforeRequired.java")
);
// Assert
assertThat(compilation).failed();
assertThat(compilation).hadErrorContaining("Required argument 'required' cannot succeed an optional argument (commandMethod)");
}
@Test
void testPrivateCommandMethodParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandMethodProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandMethodPrivate.java")
);
// Assert
assertThat(compilation).succeeded();
assertThat(compilation).hadWarningContaining("@CommandMethod annotated methods should be public (commandMethod)");
}
@Test
void testCommandMethodMissingArgumentParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandMethodProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandMethodMissingArgument.java")
);
// Assert
assertThat(compilation).failed();
assertThat(compilation).hadErrorContaining("@Argument(\"required\") is missing from @CommandMethod (commandMethod)");
}
@Test
void testCommandMethodMissingSyntaxParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandMethodProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandMethodMissingSyntax.java")
);
// Assert
assertThat(compilation).failed();
assertThat(compilation).hadErrorContaining("Argument 'optional' is missing from the @CommandMethod syntax (commandMethod)");
}
}

View file

@ -0,0 +1,13 @@
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.CommandMethod;
public class TestCommandMethod {
@CommandMethod("command <required> [optional]")
public void commandMethod(
final Object sender,
@Argument("required") final String required,
@Argument("optional") final String optional
) {
}
}

View file

@ -0,0 +1,11 @@
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.CommandMethod;
public class TestCommandMethodMissingArgument {
@CommandMethod("command <required> [optional]")
public void commandMethod(
@Argument("optional") final String optional
) {
}
}

View file

@ -0,0 +1,12 @@
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.CommandMethod;
public class TestCommandMethodMissingSyntax {
@CommandMethod("command <required>")
public void commandMethod(
@Argument("required") final String required,
@Argument("optional") final String optional
) {
}
}

View file

@ -0,0 +1,12 @@
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.CommandMethod;
public class TestCommandMethodOptionalBeforeRequired {
@CommandMethod("command [optional] <required>")
public void commandMethod(
@Argument("required") final String required,
@Argument("optional") final String optional
) {
}
}

View file

@ -0,0 +1,12 @@
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.CommandMethod;
public class TestCommandMethodPrivate {
@CommandMethod("command <required> [optional]")
private void commandMethod(
@Argument("required") final String required,
@Argument("optional") final String optional
) {
}
}

View file

@ -0,0 +1,12 @@
import cloud.commandframework.annotations.Argument;
import cloud.commandframework.annotations.CommandMethod;
public class TestCommandMethodStatic {
@CommandMethod("command <required> [optional]")
public static void commandMethod(
@Argument("required") final String required,
@Argument("optional") final String optional
) {
}
}

View file

@ -456,7 +456,7 @@ public final class ExamplePlugin extends JavaPlugin {
@CommandMethod("example help [query]")
@CommandDescription("Help menu")
private void commandHelp(
public void commandHelp(
final @NonNull CommandSender sender,
final @Argument("query") @Greedy String query
) {
@ -467,7 +467,7 @@ public final class ExamplePlugin extends JavaPlugin {
@CommandMethod("example clear")
@CommandDescription("Clear your inventory")
@CommandPermission("example.clear")
private void commandClear(final @NonNull Player player) {
public void commandClear(final @NonNull Player player) {
player.getInventory().clear();
this.bukkitAudiences.player(player)
.sendMessage(Identity.nil(), text("Your inventory has been cleared", NamedTextColor.GOLD));
@ -475,7 +475,7 @@ public final class ExamplePlugin extends JavaPlugin {
@CommandMethod("example give <material> <amount>")
@CommandDescription("Give yourself an item")
private void commandGive(
public void commandGive(
final @NonNull Player player,
final @NonNull @Argument("material") Material material,
final @Argument("amount") int number,
@ -507,7 +507,7 @@ public final class ExamplePlugin extends JavaPlugin {
@CommandMethod("example pay <money>")
@CommandDescription("Command to test the preprocessing system")
private void commandPay(
public void commandPay(
final @NonNull CommandSender sender,
final @Argument("money") @Regex(value = "(?=.*?\\d)^\\$?(([1-9]\\d{0,2}(,\\d{3})*)|\\d+)?(\\.\\d{1,2})?$",
failureCaption = "regex.money") String money
@ -520,7 +520,7 @@ public final class ExamplePlugin extends JavaPlugin {
}
@CommandMethod("example teleport complex <location>")
private void teleportComplex(
public void teleportComplex(
final @NonNull Player sender,
final @NonNull @Argument("location") Location location
) {