diff --git a/README.md b/README.md index 88984cf7e..71bb786cd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

-### Example +## Example - First, we define our Command by creating a *Command Template*. ```java @@ -28,17 +28,9 @@ @Argument.Define(names = {"age", "a"}, description = "The age of the user.", prefix = Argument.Prefix.PLUS) public int age = 18; - - @InitDef - public static void beforeInit(@NotNull CommandBuildContext ctx) { - // configure the argument "age" to have an argument type of - // number range and set the range to 1-100 - ctx.argWithType("age", new NumberRangeArgumentType<>(1, 100)) - .onOk(v -> System.out.println("The age is valid!")); - } } ``` - + - Then, let that class definition also serve as the container for the parsed values. ```java class Test { diff --git a/src/main/java/lanat/ArgumentType.java b/src/main/java/lanat/ArgumentType.java index 680c3538a..51153c690 100644 --- a/src/main/java/lanat/ArgumentType.java +++ b/src/main/java/lanat/ArgumentType.java @@ -186,6 +186,14 @@ public T getFinalValue() { return this.getValue(); // by default, the final value is just the current value. subclasses can override this. } + /** + * Sets the initial value of this argument type. + * @param initialValue The initial value of this argument type. + */ + public void setInitialValue(T initialValue) { + this.initialValue = initialValue; + } + /** * Returns the initial value of this argument type, if specified. * @return The initial value of this argument type, {@code null} if not specified. diff --git a/src/main/java/lanat/ArgumentTypeInfer.java b/src/main/java/lanat/ArgumentTypeInfer.java index 3f19f2931..61be0589f 100644 --- a/src/main/java/lanat/ArgumentTypeInfer.java +++ b/src/main/java/lanat/ArgumentTypeInfer.java @@ -7,8 +7,9 @@ import utils.exceptions.DisallowedInstantiationException; import java.io.File; -import java.util.HashMap; -import java.util.Optional; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.function.Supplier; /** @@ -34,12 +35,32 @@ private ArgumentTypeInfer() { throw new DisallowedInstantiationException(ArgumentTypeInfer.class); } + /** + * A predicate that checks if the argument type should be inferred for the specified type. + */ + public record PredicateInfer( + Predicate> predicate, + Function, ? extends ArgumentType> typeSupplier, + @NotNull String name + ) { + boolean matches(@NotNull Class clazz) { + return this.predicate.test(clazz); + } + + @SuppressWarnings("unchecked") + @NotNull ArgumentType apply(@NotNull Class clazz) { + return (ArgumentType)this.typeSupplier.apply((Class)clazz); + } + } + /** * Mapping of types to their corresponding argument types. Used for inferring. * Argument types are stored as suppliers so that we have no shared references. * */ private static final HashMap, Supplier>> INFER_ARGUMENT_TYPES_MAP = new HashMap<>(); + private static final List> PREDICATE_INFERS = new ArrayList<>(5); + /** The default range to use for argument types that accept multiple values. */ public static final Range DEFAULT_TYPE_RANGE = Range.AT_LEAST_ONE; @@ -61,6 +82,24 @@ public static void register(@NotNull Supplier> type, @ ArgumentTypeInfer.INFER_ARGUMENT_TYPES_MAP.put(clazz, type); } + /** + * Registers an argument type to be inferred for the specified type, if the predicate is true. + * The predicate will be called each time any type is required to be inferred. + * @param predicate The predicate to check if the argument type should be inferred. + * @param typeSupplier The argument type to infer. + * @param name The name of the predicate infer. + */ + public static void register( + @NotNull Predicate> predicate, + @NotNull Function, ? extends ArgumentType> typeSupplier, + @NotNull String name + ) { + if (ArgumentTypeInfer.PREDICATE_INFERS.stream().anyMatch(c -> c.name().equals(name))) + throw new IllegalArgumentException("Predicate infer already registered with name: " + name); + + ArgumentTypeInfer.PREDICATE_INFERS.add(new PredicateInfer<>(predicate, typeSupplier, name)); + } + /** * Registers an argument type to be inferred for the specified type, including the primitive form. * @param type The argument type to infer. @@ -90,6 +129,26 @@ public static void unregister(@NotNull Class clazz) { ArgumentTypeInfer.INFER_ARGUMENT_TYPES_MAP.remove(clazz); } + /** + * Removes the {@link PredicateInfer} with the specified name. + * @param name The name of the predicate infer to remove. + * @throws IllegalArgumentException If no predicate infer is found with the specified name. + */ + public static void unregister(@NotNull String name) { + if (ArgumentTypeInfer.PREDICATE_INFERS.removeIf(c -> c.name().equals(name))) + return; + + throw new IllegalArgumentException("No predicate infer registered with name: " + name); + } + + /** + * Returns a list of all the predicate infers that are registered. + * @return An unmodifiable list of all the predicate infers that are registered. + */ + public static List> getPredicateInfers() { + return Collections.unmodifiableList(ArgumentTypeInfer.PREDICATE_INFERS); + } + /** * Removes the argument type inference for the specified type, including the primitive form. * @param boxed The boxed type to unregister the argument type from. @@ -112,9 +171,20 @@ public static void unregisterWithPrimitive( * @throws ArgumentTypeInferException If no argument type is found for the specified type. */ public static @NotNull ArgumentType get(@NotNull Class clazz) { - return Optional.ofNullable(ArgumentTypeInfer.INFER_ARGUMENT_TYPES_MAP.get(clazz)) - .map(Supplier::get) - .orElseThrow(() -> new ArgumentTypeInferException(clazz)); + var infer = Optional.ofNullable(ArgumentTypeInfer.INFER_ARGUMENT_TYPES_MAP.get(clazz)) + .map(Supplier::get); + + if (infer.isPresent()) + return infer.get(); + + var predicateInfer = ArgumentTypeInfer.PREDICATE_INFERS.stream() + .filter(c -> c.matches(clazz)) + .findFirst(); + + if (predicateInfer.isPresent()) + return predicateInfer.get().apply(clazz); + + throw new ArgumentTypeInferException(clazz); } @@ -163,6 +233,7 @@ void registerWithTuple( registerWithPrimitive(BooleanArgumentType::new, Boolean.class, boolean.class); register(() -> new FileArgumentType(false), File.class); + setDefaultPredicateInfers(); registerWithTuple(IntegerArgumentType::new, Integer.class, int.class); registerWithTuple(FloatArgumentType::new, Float.class, float.class); @@ -171,4 +242,9 @@ void registerWithTuple( registerWithTuple(ShortArgumentType::new, Short.class, short.class); registerWithTuple(ByteArgumentType::new, Byte.class, byte.class); } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void setDefaultPredicateInfers() { + register(Enum.class::isAssignableFrom, c -> new EnumArgumentType(c), "EnumArgumentType"); + } } \ No newline at end of file diff --git a/src/main/java/lanat/argumentTypes/EnumArgumentType.java b/src/main/java/lanat/argumentTypes/EnumArgumentType.java index 6baebcd88..97c8164b3 100644 --- a/src/main/java/lanat/argumentTypes/EnumArgumentType.java +++ b/src/main/java/lanat/argumentTypes/EnumArgumentType.java @@ -2,6 +2,13 @@ import org.jetbrains.annotations.NotNull; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Arrays; +import java.util.Optional; + /** * An argument type that takes a valid enum value. *

@@ -13,23 +20,56 @@ public class EnumArgumentType> extends SingleValueListArgumentType { /** * Creates a new enum argument type. - * @param defaultValue The default value of the enum type. This is also used to infer the type of the enum. + * @param clazz The class of the enum type to use. */ - public EnumArgumentType(@NotNull T defaultValue) { - super(defaultValue.getDeclaringClass().getEnumConstants(), defaultValue); + public EnumArgumentType(@NotNull Class clazz) { + super(clazz.getEnumConstants()); + this.setDefault(clazz); } /** - * Creates a new enum argument type. + * Sets the default value of the enum type by using the {@link Default} annotation. * @param clazz The class of the enum type to use. */ - public EnumArgumentType(@NotNull Class clazz) { - super(clazz.getEnumConstants()); - } + private void setDefault(@NotNull Class clazz) { + var defaultFields = Arrays.stream(clazz.getDeclaredFields()) + .filter(f -> f.isAnnotationPresent(Default.class)) + .toList(); + + if (defaultFields.isEmpty()) + return; + if (defaultFields.size() > 1) + throw new IllegalArgumentException("Only one default value can be set."); + + this.setInitialValue( + Arrays.stream(this.listValues) + .filter(v -> v.name().equals(defaultFields.get(0).getName())) + .findFirst() + .orElseThrow() + ); + } @Override protected @NotNull String valueToString(@NotNull T value) { - return value.name(); + try { + return Optional.ofNullable(value.getClass().getField(value.name()).getAnnotation(WithName.class)) + .map(WithName::value) + .orElseGet(value::name); + } catch (NoSuchFieldException e) { + return value.name(); + } } + + /** An annotation that specifies the name the user will have to write to select this value. */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + public @interface WithName { + String value(); + } + + /** An annotation that specifies the default value of the enum type. */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + public @interface Default { } } \ No newline at end of file diff --git a/src/main/java/lanat/argumentTypes/SingleValueListArgumentType.java b/src/main/java/lanat/argumentTypes/SingleValueListArgumentType.java index 86a732304..26cd6ee08 100644 --- a/src/main/java/lanat/argumentTypes/SingleValueListArgumentType.java +++ b/src/main/java/lanat/argumentTypes/SingleValueListArgumentType.java @@ -1,6 +1,7 @@ package lanat.argumentTypes; import lanat.ArgumentType; +import lanat.utils.UtlMisc; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import textFormatter.FormatOption; @@ -47,14 +48,21 @@ protected SingleValueListArgumentType(@NotNull T @NotNull [] listValues) { if (this.listValues.length == 0) throw new IllegalArgumentException("The list of values cannot be empty."); - return Stream.of(this.listValues) + var sanitized = Stream.of(this.listValues) .map(this::valueToString) .map(String::trim) .peek(v -> { + if (v.isEmpty()) + throw new IllegalArgumentException("Value cannot be empty."); + if (v.chars().anyMatch(Character::isWhitespace)) throw new IllegalArgumentException("Value cannot contain spaces: '" + v + "'."); }) - .toArray(String[]::new); + .toList(); + + UtlMisc.requireUniqueElements(sanitized, e -> new IllegalArgumentException("Duplicate value: '" + e + "'.")); + + return sanitized.toArray(String[]::new); } /** @@ -75,7 +83,7 @@ public T parseValues(@NotNull String @NotNull [] values) { return this.listValues[i]; } - this.addError("Invalid value: '" + values[0] + "'."); + this.addError("Value '" + values[0] + "' not matching any in " + this.getRepresentation()); return null; } @@ -105,9 +113,16 @@ public T parseValues(@NotNull String @NotNull [] values) { @Override public @Nullable String getDescription() { + var initialValue = this.getInitialValue(); + return "Specify one of the following values: " + String.join(", ", Stream.of(this.listValuesStr).toList()) - + (this.getInitialValue() == null ? "" : (". Default is " + this.getInitialValue())) + + ( + initialValue == null + ? "" + : (". Default is " + TextFormatter.of(this.valueToString(initialValue), SimpleColor.YELLOW) + .addFormat(FormatOption.BOLD)) + ) + "."; } } \ No newline at end of file diff --git a/src/main/java/lanat/helpRepresentation/HelpFormatter.java b/src/main/java/lanat/helpRepresentation/HelpFormatter.java index 4e2bcac65..c15fede27 100644 --- a/src/main/java/lanat/helpRepresentation/HelpFormatter.java +++ b/src/main/java/lanat/helpRepresentation/HelpFormatter.java @@ -198,7 +198,7 @@ public final void removeLayoutItems(int... indices) { final var buffer = new StringBuilder(); for (int i = 0; i < this.layout.size(); i++) { - final var generatedContent = this.layout.get(i).generate(this, cmd); + final var generatedContent = this.layout.get(i).generate(cmd); if (generatedContent == null) continue; diff --git a/src/main/java/lanat/helpRepresentation/LayoutItem.java b/src/main/java/lanat/helpRepresentation/LayoutItem.java index cabc71ab6..d16711084 100644 --- a/src/main/java/lanat/helpRepresentation/LayoutItem.java +++ b/src/main/java/lanat/helpRepresentation/LayoutItem.java @@ -39,7 +39,7 @@ public static LayoutItem of(@NotNull Function<@NotNull Command, @Nullable String /** * Creates a new {@link LayoutItem} with the given {@link Supplier} that generates a {@link String}. * - * @param layoutGenerator the supplier that generates the content of the layout item + * @param layoutGenerator the typeSupplier that generates the content of the layout item * @return the new LayoutItem */ public static LayoutItem of(@NotNull Supplier<@Nullable String> layoutGenerator) { @@ -140,18 +140,16 @@ public LayoutItem withTitle(String title) { /** * Generates the content of the layout item. The reason this method requires a {@link HelpFormatter} is because it * provides the indent size and the parent command. - * - * @param helpFormatter the help formatter that is generating the help message * @return the content of the layout item */ - public @Nullable String generate(@NotNull HelpFormatter helpFormatter, @NotNull Command cmd) { + public @Nullable String generate(@NotNull Command cmd) { final var content = this.generator.apply(cmd); return (content == null || content.isEmpty()) ? null : ( System.lineSeparator().repeat(this.marginTop) + (this.title == null ? "" : this.title + System.lineSeparator().repeat(2)) // strip() is used here because trim() also removes \022 (escape character) - + UtlString.indent(content.strip(), this.indentCount * helpFormatter.getIndentSize()) + + UtlString.indent(content.strip(), this.indentCount * HelpFormatter.getIndentSize()) + System.lineSeparator().repeat(this.marginBottom) ); } diff --git a/src/test/java/lanat/test/units/TestArgumentTypes.java b/src/test/java/lanat/test/units/TestArgumentTypes.java index 5cf6b6291..b4f725fd1 100644 --- a/src/test/java/lanat/test/units/TestArgumentTypes.java +++ b/src/test/java/lanat/test/units/TestArgumentTypes.java @@ -15,7 +15,16 @@ public class TestArgumentTypes extends UnitTests { private enum TestEnum { - ONE, TWO, THREE + ONE, + @EnumArgumentType.Default + TWO, + THREE + } + + private enum TestEnum2 { + ONE, + TWO, + THREE } @Override @@ -31,8 +40,8 @@ protected TestingParser setParser() { .defaultValue(new Integer[] { 10101 }) ); this.addArgument(Argument.create(new FileArgumentType(true), "file")); - this.addArgument(Argument.create(new EnumArgumentType<>(TestEnum.TWO), "enum")); - this.addArgument(Argument.create(new EnumArgumentType<>(TestEnum.class), "enum2")); + this.addArgument(Argument.create(new EnumArgumentType<>(TestEnum.class), "enum")); + this.addArgument(Argument.create(new EnumArgumentType<>(TestEnum2.class), "enum2")); this.addArgument(Argument.create(new OptListArgumentType(List.of("foo", "bar", "qux"), "qux"), "optlist")); this.addArgument(Argument.create(new OptListArgumentType("foo", "bar", "qux"), "optlist2")); this.addArgument(Argument.create(new KeyValuesArgumentType<>(new IntegerArgumentType()), "key-value")); @@ -107,8 +116,8 @@ public void testEnum() { assertEquals(TestEnum.TWO, this.parser.parseGetValues("").get("enum").orElse(null)); // default value // test without default value - assertEquals(TestEnum.ONE, this.parseArg("enum2", "ONE")); - assertEquals(TestEnum.TWO, this.parseArg("enum2", "TWO")); + assertEquals(TestEnum2.ONE, this.parseArg("enum2", "ONE")); + assertEquals(TestEnum2.TWO, this.parseArg("enum2", "TWO")); this.assertNotPresent("enum2"); }