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:
parent
f1582fb64e
commit
74fd40f403
17 changed files with 500 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
cloud.commandframework.annotations.processing.CommandContainerProcessor
|
||||
cloud.commandframework.annotations.processing.CommandMethodProcessor
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
13
cloud-annotations/src/test/resources/TestCommandMethod.java
Normal file
13
cloud-annotations/src/test/resources/TestCommandMethod.java
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue