feat(annotations): add command containers (#364)

This is the first part of the introduction of annotation processing to cloud. A new `@CommandContainer` annotation has been introduced, which can be placed on classes to have the annotation parser automatically construct & parse the classes when `AnnotationParser.parseContainers()` is invoked.

A future PR will introduce another processor that will scan for `@CommandMethod` annotations and verify the integrity of the annotated methods (visibility, argument annotations, etc.).
This commit is contained in:
Alexander Söderberg 2022-05-31 15:37:45 +02:00 committed by Jason
parent d613fd0208
commit f1582fb64e
15 changed files with 543 additions and 2 deletions

View file

@ -12,6 +12,7 @@ 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
### Fixed
- Core: Fix missing caption registration for the regex caption ([#351](https://github.com/Incendo/cloud/pull/351))

View file

@ -5,4 +5,6 @@ plugins {
dependencies {
implementation(projects.cloudCore)
testImplementation(libs.compileTesting)
}

View file

@ -30,6 +30,7 @@ import cloud.commandframework.annotations.injection.ParameterInjectorRegistry;
import cloud.commandframework.annotations.injection.RawArgs;
import cloud.commandframework.annotations.parsers.MethodArgumentParser;
import cloud.commandframework.annotations.parsers.Parser;
import cloud.commandframework.annotations.processing.CommandContainerProcessor;
import cloud.commandframework.annotations.specifier.Completions;
import cloud.commandframework.annotations.suggestions.MethodSuggestionsProvider;
import cloud.commandframework.annotations.suggestions.Suggestions;
@ -48,16 +49,21 @@ import cloud.commandframework.extra.confirmation.CommandConfirmationManager;
import cloud.commandframework.meta.CommandMeta;
import cloud.commandframework.meta.SimpleCommandMeta;
import io.leangen.geantyref.TypeToken;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -66,6 +72,8 @@ import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
@ -318,13 +326,64 @@ public final class AnnotationParser<C> {
return Arrays.stream(strings).map(this::processString).toArray(String[]::new);
}
/**
* Parses all known {@link cloud.commandframework.annotations.processing.CommandContainer command containers}.
*
* @return Collection of parsed commands
* @throws Exception re-throws all encountered exceptions.
* @since 1.7.0
* @see cloud.commandframework.annotations.processing.CommandContainer CommandContainer for more information.
*/
public @NonNull Collection<@NonNull Command<C>> parseContainers() throws Exception {
final List<Command<C>> commands = new LinkedList<>();
final List<String> classes;
try (InputStream stream = this.getClass().getClassLoader().getResourceAsStream(CommandContainerProcessor.PATH)) {
if (stream == null) {
return Collections.emptyList();
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
classes = reader.lines().distinct().collect(Collectors.toList());
}
}
for (final String className : classes) {
final Class<?> commandContainer = Class.forName(className);
// We now have the class, and we now just need to decide what constructor to invoke.
// We first try to find a constructor which takes in the parser.
@MonotonicNonNull Object instance;
try {
instance = commandContainer.getConstructor(AnnotationParser.class).newInstance(this);
} catch (final NoSuchMethodException ignored) {
try {
// Then we try to find a no-arg constructor.
instance = commandContainer.getConstructor().newInstance();
} catch (final NoSuchMethodException e) {
// If neither are found, we panic!
throw new IllegalStateException(
String.format(
"Command container %s has no valid constructors",
commandContainer
),
e
);
}
}
commands.addAll(this.parse(instance));
}
return Collections.unmodifiableList(commands);
}
/**
* Scan a class instance of {@link CommandMethod} annotations and attempt to
* compile them into {@link Command} instances
* compile them into {@link Command} instances.
*
* @param instance Instance to scan
* @param <T> Type of the instance
* @return Collection of parsed annotations
* @return Collection of parsed commands
*/
@SuppressWarnings({"deprecation", "unchecked", "rawtypes"})
public <T> @NonNull Collection<@NonNull Command<C>> parse(final @NonNull T instance) {

View file

@ -0,0 +1,56 @@
//
// 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.AnnotationParser;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Indicates that the class contains
* {@link cloud.commandframework.annotations.CommandMethod command metods}.
* <p>
* If using <i>cloud-annotations</i> as an annotation processor, then the class will
* be listed in a special file under META-INF. These containers can be collectively
* parsed using {@link AnnotationParser#parseContainers()}, which will create instances
* of the containers and then call {@link AnnotationParser#parse(Object)} with the created instance.
* <p>
* Every class annotated with {@link CommandContainer} needs to be {@code public}, and it
* also needs to have one of the following:
* <ul>
* <li>A {@code public} no-arg constructor</li>
* <li>A {@code public} constructor with {@link AnnotationParser} as the sole parameter</li>
* </ul>
* <p>
* <b>NOTE:</b> For container parsing to work, you need to make sure that <i>cloud-annotations</i> is added
* as an annotation processor.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CommandContainer {
String ANNOTATION_PATH = "cloud.commandframework.annotations.processing.CommandContainer";
}

View file

@ -0,0 +1,115 @@
//
// 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 java.io.BufferedWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
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;
import javax.tools.Diagnostic;
import javax.tools.StandardLocation;
import org.checkerframework.checker.nullness.qual.NonNull;
@SupportedAnnotationTypes(CommandContainer.ANNOTATION_PATH)
public final class CommandContainerProcessor extends AbstractProcessor {
/**
* The file in which all command container names are stored.
*/
public static final String PATH = "META-INF/commands/cloud.commandframework.annotations.processing.CommandContainer";
@Override
public boolean process(
final @NonNull Set<? extends TypeElement> annotations,
final @NonNull RoundEnvironment roundEnv
) {
final List<String> validTypes = new ArrayList<>();
final Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(CommandContainer.class);
if (elements.isEmpty()) {
return false; // Nothing to process...
}
for (final Element element : elements) {
if (element.getKind() != ElementKind.CLASS) {
this.processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format(
"@CommandMethod found on unsupported element type '%s' (%s)",
element.getKind().name(),
element.getSimpleName().toString()
),
element
);
return false;
}
element.accept(new CommandContainerVisitor(this.processingEnv, validTypes), null);
}
for (final String type : validTypes) {
this.processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE,
String.format(
"Found valid @CommandMethod annotated class: %s",
type
)
);
}
this.writeCommandFile(validTypes);
// https://errorprone.info/bugpattern/DoNotClaimAnnotations
return false;
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latest();
}
@SuppressWarnings({"unused", "try"})
private void writeCommandFile(final @NonNull List<String> types) {
try (BufferedWriter writer = new BufferedWriter(this.processingEnv.getFiler().createResource(
StandardLocation.CLASS_OUTPUT,
"",
PATH
).openWriter())) {
for (final String t : types) {
writer.write(t);
writer.newLine();
}
writer.flush();
} catch (final IOException e) {
e.printStackTrace();
}
}
}

View file

@ -0,0 +1,159 @@
//
// 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 java.util.Arrays;
import java.util.Collection;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
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;
final class CommandContainerVisitor implements ElementVisitor<Void, Void> {
private static final Collection<String> PERMITTED_CONSTRUCTOR_PARAMETER_TYPES = Arrays.asList(
"cloud.commandframework.annotations.AnnotationParser"
);
private final ProcessingEnvironment processingEnvironment;
private final Collection<String> validTypes;
private boolean suitableConstructorFound;
CommandContainerVisitor(
final @NonNull ProcessingEnvironment processingEnvironment,
final @NonNull Collection<@NonNull String> validTypes
) {
this.processingEnvironment = processingEnvironment;
this.validTypes = validTypes;
this.suitableConstructorFound = false;
}
@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) {
if (!e.getModifiers().contains(Modifier.PUBLIC)) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format("@CommandMethod annotated class must be public (%s)", e.getSimpleName()),
e
);
}
for (final Element enclosedElement : e.getEnclosedElements()) {
if (enclosedElement.getKind() != ElementKind.CONSTRUCTOR) {
continue;
}
// Visit the constructor.
enclosedElement.accept(this, null);
// If we've already found a suitable constructor, there's no
// need to search for more.
if (this.suitableConstructorFound) {
break;
}
}
// When we've visited every constructor, we verify that we found
// at least one suitable constructor.
if (!this.suitableConstructorFound) {
this.processingEnvironment.getMessager().printMessage(
Diagnostic.Kind.ERROR,
String.format("@CommandMethod must have a suitable constructor (%s)", e.getSimpleName()),
e
);
}
// We know it's valid, so we'll add it to the list of valid types.
this.validTypes.add(e.asType().toString());
return null;
}
@Override
public Void visitVariable(final VariableElement e, final Void unused) {
return null;
}
@Override
public Void visitExecutable(final ExecutableElement e, final Void unused) {
// We only want to process public constructors.
if (!e.getModifiers().contains(Modifier.PUBLIC)) {
return null;
}
// Now we need to verify that the paramters are correct.
final boolean containsIllegalParameter = e.getParameters()
.stream()
.map(parameter -> parameter.asType().toString())
// Ignore annotations.
.map(typeString -> typeString.split(" "))
.map(parts -> parts[parts.length - 1])
// Ignore type parameters.
.map(part -> part.split("<")[0])
.anyMatch(type -> !PERMITTED_CONSTRUCTOR_PARAMETER_TYPES.contains(type));
if (containsIllegalParameter) {
return null;
}
// We now know that there's a constructor which accepts the permitted types,
// and is public - Yay.
this.suitableConstructorFound = true;
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

@ -0,0 +1,6 @@
/**
* Annotation-processing related classes.
*
* @since 1.7.0
*/
package cloud.commandframework.annotations.processing;

View file

@ -0,0 +1 @@
cloud.commandframework.annotations.processing.CommandContainerProcessor

View file

@ -0,0 +1,60 @@
//
// 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.common.truth.StringSubject;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import javax.tools.StandardLocation;
import org.junit.jupiter.api.Test;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
class CommandContainerProcessorTest {
@Test
void testCommandContainerParsing() {
// Arrange
final Compiler compiler = javac().withProcessors(new CommandContainerProcessor());
// Act
final Compilation compilation = compiler.compile(
JavaFileObjects.forResource("TestCommandContainer.java"),
JavaFileObjects.forResource("TestCommandContainer2.java")
);
// Assert
assertThat(compilation).succeeded();
final StringSubject contentSubject = assertThat(compilation).generatedFile(
StandardLocation.CLASS_OUTPUT,
"" /* package */,
CommandContainerProcessor.PATH
).contentsAsUtf8String();
contentSubject.contains("TestCommandContainer");
contentSubject.contains("TestCommandContainer2");
}
}

View file

@ -0,0 +1,6 @@
import cloud.commandframework.annotations.processing.CommandContainer;
@CommandContainer
public class TestCommandContainer {
}

View file

@ -0,0 +1,10 @@
import cloud.commandframework.annotations.AnnotationParser;
import cloud.commandframework.annotations.processing.CommandContainer;
@CommandContainer
public class TestCommandContainer2 {
public TestCommandContainer2(final AnnotationParser parser) {
// ...
}
}

View file

@ -18,6 +18,8 @@ dependencies {
implementation(libs.adventurePlatformBukkit)
/* Bukkit */
compileOnly(libs.bukkit)
/* Annotation processing */
annotationProcessor(project(":cloud-annotations"))
}
tasks {

View file

@ -0,0 +1,53 @@
//
// 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.examples.bukkit;
import cloud.commandframework.annotations.AnnotationParser;
import cloud.commandframework.annotations.CommandMethod;
import cloud.commandframework.annotations.processing.CommandContainer;
import org.bukkit.command.CommandSender;
import org.checkerframework.checker.nullness.qual.NonNull;
@CommandContainer
public final class ExampleCommandContainer {
/**
* The constructor. {@link AnnotationParser} is an optional parameter.
*
* @param parser the parser
*/
public ExampleCommandContainer(final @NonNull AnnotationParser<CommandSender> parser) {
// Woo...
}
/**
* This one gets parsed automatically!
*
* @param sender the sender
*/
@CommandMethod("container")
public void containerCommand(final CommandSender sender) {
sender.sendMessage("This is sent from a container!!");
}
}

View file

@ -235,6 +235,12 @@ public final class ExamplePlugin extends JavaPlugin {
// Parse all @CommandMethod-annotated methods
//
this.annotationParser.parse(this);
// Parse all @CommandContainer-annotated classes
try {
this.annotationParser.parseContainers();
} catch (final Exception e) {
e.printStackTrace();
}
//
// Base command builder
//

View file

@ -54,6 +54,7 @@ versions:
mockitoKotlin : 4.0.0
mockitoJupiter: 4.5.1
truth : 1.1.3
compileTesting: 0.19
# build-logic
indra: 2.1.1
@ -223,6 +224,10 @@ dependencies:
group: com.google.truth.extensions
name: truth-java8-extension
version: { ref: truth }
compileTesting:
group: com.google.testing.compile
name: compile-testing
version: { ref: compileTesting }
# build-logic
indraCommon: