diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index b4f9c02c..1ce13e81 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -1,10 +1,3 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - name: Publish to Github Packages on: diff --git a/README.md b/README.md index 1a83a354..06084cf1 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,67 @@ -# Lanat - -Lanat is a command line argument parser for Java 17 with ease of use and high customization -possibilities in mind. - -### Examples -Here is an example of a simple argument parser definition. - -```java -@Command.Define -class MyProgram { - @Argument.Define(required = true, positional = true, description = "The name of the user.") - public String name; - - @Argument.Define(argType = StringArgumentType.class, description = "The surname of the user.") - public Optional surname; - - @Argument.Define(names = {"age", "a"}, description = "The age of the user.", prefix = '+') - public int age = 18; +
+
+ +
+
+ + A command line argument parser for Java 17 with
+ ease of use and high customization possibilities in mind. +
+
+ +

+ + +### Example +- First, we define our Command by creating a *Command Template*. - @InitDef - public static void beforeInit(@NotNull CommandBuildHelper cmdBuildHelper) { - // configure the argument "age" to have an argument type of - // number range and set the range to 1-100 - cmdBuildHelper., Integer>getArgument("age") - .withArgType(new NumberRangeArgumentType<>(1, 100)) - .onOk(v -> System.out.println("The age is valid!")); - } -} - -class Test { - public static void main(String[] args) { - // example: david +a20 - var myProgram = ArgumentParser.parseFromInto(MyProgram.class, CLInput.from(args)); + ```java + @Command.Define + class MyProgram { + @Argument.Define(required = true, positional = true, description = "The name of the user.") + public String name; + + @Argument.Define(argType = StringArgumentType.class, description = "The surname of the user.") + public Optional surname; + + @Argument.Define(names = {"age", "a"}, description = "The age of the user.", prefix = '+') + public int age = 18; - System.out.printf( - "Welcome %s! You are %d years old.%n", - myProgram.name, myProgram.age - ); - - // if no surname was specified, we'll show "none" instead - System.out.printf("The surname of the user is %s.%n", myProgram.surname.orElse("none")); + @InitDef + public static void beforeInit(@NotNull CommandBuildHelper cmdBuildHelper) { + // configure the argument "age" to have an argument type of + // number range and set the range to 1-100 + cmdBuildHelper., Integer>getArgument("age") + .withArgType(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 { + public static void main(String[] args) { + // example: david +a20 + var myProgram = ArgumentParser.parseFromInto(MyProgram.class, CLInput.from(args)); + + System.out.printf( + "Welcome %s! You are %d years old.%n", + myProgram.name, myProgram.age + ); + + // if no surname was specified, we'll show "none" instead + System.out.printf("The surname of the user is %s.%n", myProgram.surname.orElse("none")); + } } -} -``` + ``` ## Documentation Javadoc documentation for the latest stable version is available [here](https://darvil82.github.io/Lanat/). +Deep documentation and tutorials comming soon. + ## Installation @@ -55,7 +69,7 @@ The package is currently only available on GitHub Packages. ### Gradle -1. Authenticate to GitHub Packages in order to be able to download the package. You can do this by adding the following to your [gradle.properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties) file: +1. Authenticate to GitHub Packages to be able to download the package. You can do this by adding the following to your [gradle.properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties) file: ``` gpr.user=USERNAME @@ -70,8 +84,8 @@ The package is currently only available on GitHub Packages. maven { url = uri("https://maven.pkg.github.com/darvil82/lanat") credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("CI_GITHUB_USERNAME") - password = project.findProperty("gpr.key") as String? ?: System.getenv("CI_GITHUB_PASSWORD") + username = project.findProperty("gpr.user") as String? + password = project.findProperty("gpr.key") as String? } } ``` @@ -80,9 +94,9 @@ The package is currently only available on GitHub Packages. ```kotlin implementation("darvil:lanat") - ``` - - Note that you may need to explicitly specify the version of the package you want to use. (e.g. `darvil:lanat:x.x.x`) + ``` + > [!NOTE] + > You may need to explicitly specify the version of the package you want to use. (e.g. `darvil:lanat:x.x.x`). This information is available at the [GitHub Packages documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#using-a-published-package). diff --git a/build.gradle.kts b/build.gradle.kts index 15dcd597..cc7ebef4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "darvil" -version = "0.0.3" +version = "0.1.0" description = "Command line argument parser" dependencies { diff --git a/src/main/java/lanat/Argument.java b/src/main/java/lanat/Argument.java index a5cf726b..a5650406 100644 --- a/src/main/java/lanat/Argument.java +++ b/src/main/java/lanat/Argument.java @@ -15,10 +15,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Stream; /** @@ -111,59 +111,6 @@ public class Argument, TInner> private final @NotNull ModifyRecord representationColor = ModifyRecord.empty(); - /** - * The list of prefixes that can be used. - *

- * The {@link PrefixChar#AUTO} prefix will be automatically set depending on the Operating System. - *

- * - * @see PrefixChar#AUTO - */ - public static class PrefixChar { - public static final PrefixChar MINUS = new PrefixChar('-'); - public static final PrefixChar PLUS = new PrefixChar('+'); - public static final PrefixChar SLASH = new PrefixChar('/'); - public static final PrefixChar AT = new PrefixChar('@'); - public static final PrefixChar PERCENT = new PrefixChar('%'); - public static final PrefixChar CARET = new PrefixChar('^'); - public static final PrefixChar EXCLAMATION = new PrefixChar('!'); - public static final PrefixChar TILDE = new PrefixChar('~'); - public static final PrefixChar QUESTION = new PrefixChar('?'); - public static final PrefixChar EQUALS = new PrefixChar('='); - public static final PrefixChar COLON = new PrefixChar(':'); - - /** - * This prefix will be automatically set depending on the Operating System. On Linux, it will be - * {@link PrefixChar#MINUS}, and on Windows, it will be {@link PrefixChar#SLASH}. - */ - public static final PrefixChar AUTO = System.getProperty("os.name").toLowerCase().contains("win") ? SLASH : MINUS; - - - public final char character; - public static @NotNull PrefixChar defaultPrefix = PrefixChar.MINUS; - - private PrefixChar(char character) { - this.character = character; - } - - /** - * Creates a new PrefixChar with the specified non-whitespace character. - *

- * NOTE:
- * The constant fields of this class should be used instead of this method. Other characters could break - * compatibility with shells using special characters as prefixes, such as the | or ; - * characters. - *

- * - * @param character the character that will be used as a prefix - */ - public static @NotNull PrefixChar fromCharUnsafe(char character) { - if (Character.isWhitespace(character)) - throw new IllegalArgumentException("The character cannot be a whitespace character."); - return new PrefixChar(character); - } - } - Argument(@NotNull Type type, @NotNull String... names) { this.argType = type; this.addNames(names); @@ -270,6 +217,8 @@ public boolean isPositional() { * Specify the prefix of this argument. By default, this is {@link PrefixChar#MINUS}. If this argument is used in an * argument name list (-abc), the prefix that will be valid is any against all the arguments specified in that name * list. + *

+ * Note that, for ease of use, the prefixes defined in {@link PrefixChar#COMMON_PREFIXES} are also valid. * * @param prefixChar the prefix that should be used for this argument. * @see PrefixChar @@ -329,7 +278,10 @@ public void setDefaultValue(@Nullable TInner value) { */ @Override public void addNames(@NotNull String... names) { - Arrays.stream(names) + if (names.length == 0) + throw new IllegalArgumentException("at least one name must be specified"); + + Stream.of(names) .map(UtlString::requireValidName) .peek(n -> { if (this.names.contains(n)) @@ -455,16 +407,6 @@ public boolean isHelpArgument() { * @param values The value array that should be parsed. */ public void parseValues(short tokenIndex, @NotNull String... values) { - // check if the argument was used more times than it should - if (++this.argType.usageCount > this.argType.getRequiredUsageCount().end()) { - this.parentCommand.getParser() - .addError( - ParseError.ParseErrorType.ARG_INCORRECT_USAGES_COUNT, - this, values.length, this.argType.getLastTokenIndex() + 1 - ); - return; - } - this.argType.parseAndUpdateValue(tokenIndex, values); } @@ -497,19 +439,30 @@ public void parseValues(short tokenIndex, @NotNull String... values) { * @return {@code true} if the argument was used the correct amount of times. */ private boolean finishParsing$checkUsageCount() { - if (this.getUsageCount() == 0) { - if (this.required && !this.parentCommand.uniqueArgumentReceivedValue()) { + final var usageCount = this.getUsageCount(); + + if (usageCount == 0) { + if (this.required && !this.parentCommand.uniqueArgumentReceivedValue(this)) { this.parentCommand.getParser().addError( + // just show it at the end. doesnt really matter ParseError.ParseErrorType.REQUIRED_ARGUMENT_NOT_USED, this, 0 ); - return false; } - // make sure that the argument was used the minimum amount of times specified - } else if (this.argType.usageCount < this.argType.getRequiredUsageCount().start()) { + return false; + } + + // make sure that the argument was used the minimum number of times specified + if (!this.argType.getRequiredUsageCount().isInRangeInclusive(usageCount)) { this.parentCommand.getParser() - .addError(ParseError.ParseErrorType.ARG_INCORRECT_USAGES_COUNT, this, 0); + .addError( + ParseError.ParseErrorType.ARG_INCORRECT_USAGES_COUNT, + this, + this.argType.getLastReceivedValuesNum(), + this.argType.getLastTokenIndex() + ); return false; } + return true; } @@ -530,7 +483,8 @@ public void parseValues(short tokenIndex, @NotNull String... values) { new ParseError( ParseError.ParseErrorType.MULTIPLE_ARGS_IN_EXCLUSIVE_GROUP_USED, this.argType.getLastTokenIndex(), - this, this.argType.getLastReceivedValuesNum() + this, + this.argType.getLastReceivedValuesNum() ) {{ this.setArgumentGroup(exclusivityResult); @@ -566,6 +520,13 @@ public boolean checkMatch(char name) { return this.hasName(Character.toString(name)); } + /** + * Executes the correct or the error callback depending on whether the argument has errors or not. + *

+ * The correct callback is only executed if the argument has no errors, the usage count is greater than 0, the + * + * @param okValue the value to pass to the correct callback + */ // no worries about casting here, it will always receive the correct type @SuppressWarnings("unchecked") void invokeCallbacks(@Nullable Object okValue) { @@ -578,7 +539,6 @@ void invokeCallbacks(@Nullable Object okValue) { if (okValue == null || this.onCorrectCallback == null || this.getUsageCount() == 0 - || (!this.allowUnique && this.parentCommand.uniqueArgumentReceivedValue()) || !this.parentCommand.shouldExecuteCorrectCallback() ) return; @@ -668,9 +628,11 @@ public void resetState() { /** * Specifies the prefix character for this argument. This uses {@link PrefixChar#fromCharUnsafe(char)}. + *

+ * By default, this is set to the value of {@link PrefixChar#defaultPrefix}. * @see Argument#setPrefix(PrefixChar) * */ - char prefix() default '-'; + char prefix() default Character.MAX_VALUE; // Character.MAX_VALUE will be replaced with PrefixChar.defaultPrefix /** @see Argument#setRequired(boolean) */ boolean required() default false; @@ -683,6 +645,62 @@ public void resetState() { } + /** + * Specifies the prefix character for an {@link Argument}. + */ + public static class PrefixChar { + public static final PrefixChar MINUS = new PrefixChar('-'); + public static final PrefixChar PLUS = new PrefixChar('+'); + public static final PrefixChar SLASH = new PrefixChar('/'); + public static final PrefixChar AT = new PrefixChar('@'); + public static final PrefixChar PERCENT = new PrefixChar('%'); + public static final PrefixChar CARET = new PrefixChar('^'); + public static final PrefixChar EXCLAMATION = new PrefixChar('!'); + public static final PrefixChar TILDE = new PrefixChar('~'); + public static final PrefixChar QUESTION = new PrefixChar('?'); + public static final PrefixChar EQUALS = new PrefixChar('='); + public static final PrefixChar COLON = new PrefixChar(':'); + + /** + * This prefix will be automatically set depending on the Operating System. On Linux, it will be + * {@link PrefixChar#MINUS}, and on Windows, it will be {@link PrefixChar#SLASH}. + */ + public static final PrefixChar AUTO = System.getProperty("os.name").toLowerCase().contains("win") ? SLASH : MINUS; + + + public final char character; + public static @NotNull PrefixChar defaultPrefix = PrefixChar.AUTO; + + /** Prefixes that a user may be familiar with. */ + public static final @NotNull PrefixChar[] COMMON_PREFIXES = { MINUS, SLASH }; + + + private PrefixChar(char character) { + this.character = character; + } + + /** + * Creates a new {@link PrefixChar} with the specified non-whitespace character. + *

+ * NOTE:
+ * The constant fields of this class should be used instead of this method. Other characters could break + * compatibility with shells using special characters as prefixes, such as the | or ; + * characters. + *

+ * + * @param character the character that will be used as a prefix. {@link Character#MAX_VALUE} will return + * {@link PrefixChar#defaultPrefix}. + */ + public static @NotNull PrefixChar fromCharUnsafe(char character) { + if (character == Character.MAX_VALUE) + return PrefixChar.defaultPrefix; + if (Character.isWhitespace(character)) + throw new IllegalArgumentException("The character cannot be a whitespace character."); + return new PrefixChar(character); + } + } + + // ------------------------------------------------ Error Handling ------------------------------------------------ // just act as a proxy to the type error handling diff --git a/src/main/java/lanat/ArgumentParser.java b/src/main/java/lanat/ArgumentParser.java index 25ff4314..8d2f45ee 100644 --- a/src/main/java/lanat/ArgumentParser.java +++ b/src/main/java/lanat/ArgumentParser.java @@ -12,7 +12,6 @@ import java.lang.reflect.Array; import java.lang.reflect.Field; import java.lang.reflect.Modifier; -import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -151,18 +150,16 @@ public static ArgumentParser from(@NotNull Class temp @SuppressWarnings("unchecked") private static void from$setCommands(@NotNull Class templateClass, @NotNull Command parentCommand) { - final var commandDefs = Arrays.stream(templateClass.getDeclaredClasses()) + Stream.of(templateClass.getDeclaredClasses()) .filter(c -> c.isAnnotationPresent(Command.Define.class)) .filter(c -> Modifier.isStatic(c.getModifiers())) .filter(CommandTemplate.class::isAssignableFrom) .map(c -> (Class)c) - .toList(); - - for (var commandDef : commandDefs) { - var command = new Command(commandDef); - parentCommand.addCommand(command); - ArgumentParser.from$setCommands(commandDef, command); - } + .forEach(cmdDef -> { + var command = new Command(cmdDef); + parentCommand.addCommand(command); + ArgumentParser.from$setCommands(cmdDef, command); + }); } @@ -181,10 +178,14 @@ public static ArgumentParser from(@NotNull Class temp // pass the properties of this Sub-Command to its children recursively (most of the time this is what the user will want) this.passPropertiesToChildren(); this.tokenize(input.args); // first. This will tokenize all Sub-Commands recursively + var errorHandler = new ErrorHandler(this); - this.parseTokens(); // same thing, this parses all the stuff recursively - this.invokeCallbacks(); + // do not parse anything if there are any errors in the tokenizer + if (!this.getTokenizer().hasExitErrors()) { + this.parseTokens(); // same thing, this parses all the stuff recursively + this.invokeCallbacks(); + } this.isParsed = true; @@ -356,80 +357,92 @@ public T into(@NotNull Class clazz) { /** * {@link #into(Class)} helper method. - * @param clazz The Command Template class to instantiate. + * @param templateClass The Command Template class to instantiate. * @param parsedArgs The parsed arguments to set the fields of the Command Template class. */ private static T into( - @NotNull Class clazz, + @NotNull Class templateClass, @NotNull ParsedArguments parsedArgs ) { - final T instance = UtlReflection.instantiate(clazz); + final T instance = UtlReflection.instantiate(templateClass); - Stream.of(clazz.getFields()) + // set the values of the fields + Stream.of(templateClass.getFields()) .filter(f -> f.isAnnotationPresent(Argument.Define.class)) - .forEach(f -> { - final var annotation = f.getAnnotation(Argument.Define.class); - - // get the name of the argument from the annotation or field name - final String argName = annotation.names().length == 0 ? f.getName() : annotation.names()[0]; - - final @NotNull Optional parsedValue = parsedArgs.get(argName); - - try { - // if the field has a value already set and the parsed value is empty, skip it (keep the old value) - if (parsedValue.isEmpty() && f.get(instance) != null) - return; - - // if the type of the field is an Optional, wrap the value in it. - // otherwise, just set the value - f.set( - instance, - f.getType().isAssignableFrom(Optional.class) - ? parsedValue - : AfterParseOptions.into$getNewFieldValue(f, parsedValue) - ); - } catch (IllegalArgumentException e) { - if (parsedValue.isEmpty()) - throw new IncompatibleCommandTemplateType( - "Field '" + f.getName() + "' of type '" + f.getType().getSimpleName() + "' does not" - + " accept null values, but the parsed argument '" + argName + "' is null" - ); - - throw new IncompatibleCommandTemplateType( - "Field '" + f.getName() + "' of type '" + f.getType().getSimpleName() + "' is not " - + "compatible with the type (" + parsedValue.get().getClass().getSimpleName() + ") of the " - + "parsed argument '" + argName + "'" - ); - - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - }); + .forEach(field -> AfterParseOptions.into$setFieldValue(field, parsedArgs, instance)); // now handle the sub-command field accessors (if any) - final var declaredClasses = Stream.of(clazz.getDeclaredClasses()) + Stream.of(templateClass.getDeclaredClasses()) .filter(c -> c.isAnnotationPresent(Command.Define.class)) - .toList(); - - for (var cls : declaredClasses) { - final var field = Stream.of(clazz.getDeclaredFields()) - .filter(f -> f.isAnnotationPresent(CommandTemplate.CommandAccessor.class)) - .filter(f -> f.getType() == cls) - .findFirst() - .orElseThrow(() -> { - throw new CommandTemplateException( - "The class '" + cls.getSimpleName() + "' is annotated with @Command.Define but it's " - + "enclosing class does not have a field annotated with @CommandAccessor" - ); - }); - - AfterParseOptions.into$handleCommandAccessor(instance, field, parsedArgs); - } + .forEach(cmdDef -> { + var commandAccesorField = Stream.of(templateClass.getDeclaredFields()) + .filter(f -> f.isAnnotationPresent(CommandTemplate.CommandAccessor.class)) + .filter(f -> f.getType() == cmdDef) + .findFirst() + .orElseThrow(() -> { + throw new CommandTemplateException( + "The class '" + cmdDef.getSimpleName() + "' is annotated with @Command.Define but it's " + + "enclosing class does not have a field annotated with @CommandAccessor" + ); + }); + + AfterParseOptions.into$handleCommandAccessor(instance, commandAccesorField, parsedArgs); + }); return instance; } + /** + * {@link #into(Class)} helper method. Sets the value of the given field based on the parsed arguments. + * @param field The field to set the value of. + * @param parsedArgs The parsed arguments to set the field value from. + * @param instance The instance of the current Command Template class. + * @param The type of the Command Template class. + */ + private static void into$setFieldValue( + @NotNull Field field, + @NotNull ParsedArguments parsedArgs, + @NotNull T instance + ) { + final var annotation = field.getAnnotation(Argument.Define.class); + + // get the name of the argument from the annotation or field name + final String argName = annotation.names().length == 0 ? field.getName() : annotation.names()[0]; + + final @NotNull Optional parsedValue = parsedArgs.get(argName); + + try { + // if the field has a value already set and the parsed value is empty, skip it (keep the old value) + if (parsedValue.isEmpty() && field.get(instance) != null) + return; + + // if the type of the field is an Optional, wrap the value in it. + // otherwise, just set the value + field.set( + instance, + field.getType().isAssignableFrom(Optional.class) + ? parsedValue + : AfterParseOptions.into$getNewFieldValue(field, parsedValue) + ); + } catch (IllegalArgumentException e) { + if (parsedValue.isEmpty()) + throw new IncompatibleCommandTemplateType( + "Field '" + field.getName() + "' of type '" + field.getType().getSimpleName() + "' does not" + + " accept null values, but the parsed argument '" + argName + "' is null" + ); + + throw new IncompatibleCommandTemplateType( + "Field '" + field.getName() + "' of type '" + field.getType().getSimpleName() + "' is not " + + "compatible with the type (" + parsedValue.get().getClass().getSimpleName() + ") of the " + + "parsed argument '" + argName + "'" + ); + + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + /** * {@link #into(Class)} helper method. Handles the {@link CommandTemplate.CommandAccessor} annotation. * @param parsedTemplateInstance The instance of the current Command Template class. diff --git a/src/main/java/lanat/ArgumentType.java b/src/main/java/lanat/ArgumentType.java index 49e9c910..02400193 100644 --- a/src/main/java/lanat/ArgumentType.java +++ b/src/main/java/lanat/ArgumentType.java @@ -1,6 +1,9 @@ package lanat; -import lanat.argumentTypes.*; +import lanat.argumentTypes.FromParseableArgumentType; +import lanat.argumentTypes.IntegerArgumentType; +import lanat.argumentTypes.Parseable; +import lanat.exceptions.ArgumentTypeException; import lanat.parsing.errors.CustomError; import lanat.utils.ErrorsContainerImpl; import lanat.utils.Range; @@ -102,6 +105,7 @@ public ArgumentType() { * @param values The values to parse. */ public final void parseAndUpdateValue(short tokenIndex, @NotNull String... values) { + this.usageCount++; this.lastTokenIndex = tokenIndex; this.lastReceivedValuesNum = values.length; this.currentValue = this.parseValues(values); @@ -234,7 +238,7 @@ protected void addError(@NotNull String message, int index, @NotNull ErrorLevel @Override public void addError(@NotNull CustomError error) { if (this.lastTokenIndex == -1) { - throw new IllegalStateException("Cannot add an error to an argument that has not been parsed yet."); + throw new ArgumentTypeException("Cannot add an error to an argument that has not been parsed yet."); } // the index of the error should never be less than 0 or greater than the max value count diff --git a/src/main/java/lanat/ArgumentTypeInfer.java b/src/main/java/lanat/ArgumentTypeInfer.java index cb9d88b4..7e2345a1 100644 --- a/src/main/java/lanat/ArgumentTypeInfer.java +++ b/src/main/java/lanat/ArgumentTypeInfer.java @@ -9,6 +9,24 @@ import java.util.HashMap; import java.util.function.Supplier; +/** + *

Argument Type Inferrring

+ *

+ * Handles inferring argument types for specified types. This is used mostly for defining {@link Argument}s in + * {@link CommandTemplate}s. + *

+ *

Example:

+ *

+ * When defining an {@link Argument}, like this: + *

{@code
+ * @Argument.Define
+ * public Double[] numbers;
+ * }
+ *

+ * In this case, {@link ArgumentTypeInfer#get(Class)} is called with the type {@code Double[]}, which will return a + * {@link MultipleNumbersArgumentType} instance ready to be used for that value type: + *

{@code new MultipleNumbersArgumentType(Range.AT_LEAST_ONE, new Double[] {})}.
+ */ public class ArgumentTypeInfer { /** * Mapping of types to their corresponding argument types. Used for inferring. diff --git a/src/main/java/lanat/Command.java b/src/main/java/lanat/Command.java index 7894d4fc..4413c22c 100644 --- a/src/main/java/lanat/Command.java +++ b/src/main/java/lanat/Command.java @@ -18,7 +18,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.InvocationTargetException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Stream; @@ -47,7 +50,7 @@ public class Command private final @NotNull ArrayList<@NotNull Command> subCommands = new ArrayList<>(); private Command parentCommand; private final @NotNull ArrayList<@NotNull ArgumentGroup> argumentGroups = new ArrayList<>(); - private final @NotNull ModifyRecord<@NotNull TupleCharacter> tupleChars = ModifyRecord.of(TupleCharacter.SQUARE_BRACKETS); + private final @NotNull ModifyRecord<@NotNull TupleChar> tupleChars = ModifyRecord.of(TupleChar.SQUARE_BRACKETS); private final @NotNull ModifyRecord<@NotNull Integer> errorCode = ModifyRecord.of(1); // error handling callbacks @@ -59,7 +62,7 @@ public class Command ModifyRecord.of(CallbacksInvocationOption.NO_ERROR_IN_ALL_COMMANDS); /** A pool of the colors that an argument may have when being represented on the help. */ - final @NotNull LoopPool<@NotNull Color> colorsPool = LoopPool.atRandomIndex(Color.BRIGHT_COLORS.toArray(Color[]::new)); + final @NotNull LoopPool<@NotNull Color> colorsPool = LoopPool.atRandomIndex(Color.BRIGHT_COLORS); /** @@ -185,17 +188,20 @@ public void setErrorCode(int errorCode) { this.errorCode.set(errorCode); } - public void setTupleChars(@NotNull TupleCharacter tupleChars) { + public void setTupleChars(@NotNull TupleChar tupleChars) { this.tupleChars.set(tupleChars); } - public @NotNull TupleCharacter getTupleChars() { + public @NotNull TupleChar getTupleChars() { return this.tupleChars.get(); } @Override public void addNames(@NotNull String... names) { - Arrays.stream(names) + if (names.length == 0) + throw new IllegalArgumentException("at least one name must be specified"); + + Stream.of(names) .map(UtlString::requireValidName) .peek(newName -> { if (this.hasName(newName)) @@ -268,9 +274,11 @@ public void addError(@NotNull String message, @NotNull ErrorLevel level) { * Returns {@code true} if an argument with allowsUnique set in the command was used. * @return {@code true} if an argument with {@link Argument#setAllowUnique(boolean)} in the command was used. */ - boolean uniqueArgumentReceivedValue() { - return this.arguments.stream().anyMatch(a -> a.getUsageCount() >= 1 && a.isUniqueAllowed()) - || this.subCommands.stream().anyMatch(Command::uniqueArgumentReceivedValue); + boolean uniqueArgumentReceivedValue(@Nullable Argument exclude) { + return this.arguments.stream() + .filter(a -> a != exclude) + .anyMatch(a -> a.getUsageCount() >= 1 && a.isUniqueAllowed()) + || this.subCommands.stream().anyMatch(cmd -> cmd.uniqueArgumentReceivedValue(exclude)); } diff --git a/src/main/java/lanat/CommandTemplate.java b/src/main/java/lanat/CommandTemplate.java index bb4df964..39d4a969 100644 --- a/src/main/java/lanat/CommandTemplate.java +++ b/src/main/java/lanat/CommandTemplate.java @@ -131,6 +131,7 @@ public record CommandBuildHelper(@NotNull Command cmd, @NotNull List The type of the argument. * @param The type of the value passed to the argument. + * @throws ArgumentNotFoundException If there is no argument with the given name. */ @SuppressWarnings("unchecked") public , TInner> @@ -208,7 +209,7 @@ public static class Default extends CommandTemplate { /* * The reason we add these arguments here is so that they do not "physically" appear in the * actual class that extends this one. 'help' and 'version' are just - * arguments that execute actions, and they not really provide any useful values. + * arguments that execute actions, and they don't really provide any useful values. */ @InitDef public static void afterInit(@NotNull Command cmd) { diff --git a/src/main/java/lanat/ErrorFormatter.java b/src/main/java/lanat/ErrorFormatter.java index eafce167..872f16e4 100644 --- a/src/main/java/lanat/ErrorFormatter.java +++ b/src/main/java/lanat/ErrorFormatter.java @@ -72,20 +72,20 @@ public String toString() { /** * Indicates the generator to display all tokens. *

- * Tokens between the index {@code start} and the {@code offset} from it will be highlighted. If {@code placeArrow} + * Tokens between the index {@code start} and the {@code offsetEnd} from it will be highlighted. If {@code showArrows} * is {@code true}, an arrow will be placed at each token index in that range. *

* @param start The index of the first token to highlight. - * @param offset The number of tokens to highlight after the token at the index {@code start}. A value of {@code 0} + * @param offsetEnd The number of tokens to highlight after the token at the index {@code start}. A value of {@code 0} * may be used to highlight only the token at the index {@code start}. - * @param placeArrow Whether to place an arrow at each token index in the range. + * @param showArrows Whether to place an arrow at each token index in the range. */ - public ErrorFormatter displayTokens(int start, int offset, boolean placeArrow) { + public ErrorFormatter displayTokens(int start, int offsetEnd, boolean showArrows) { final var startTokenIndex = this.mainErrorHandler.getAbsoluteCmdTokenIndex() + start; this.tokensViewOptions = new DisplayTokensOptions( - Range.from(startTokenIndex).to(startTokenIndex + offset), - placeArrow + Range.from(startTokenIndex).to(startTokenIndex + offsetEnd), + showArrows ); return this; } @@ -102,7 +102,7 @@ public ErrorFormatter displayTokens(int index) { /** * Options used to display tokens. */ - public record DisplayTokensOptions(@NotNull Range tokensRange, boolean placeArrow) { } + public record DisplayTokensOptions(@NotNull Range tokensRange, boolean showArrows) { } /** diff --git a/src/main/java/lanat/ErrorLevel.java b/src/main/java/lanat/ErrorLevel.java index dae2818e..b98233bc 100644 --- a/src/main/java/lanat/ErrorLevel.java +++ b/src/main/java/lanat/ErrorLevel.java @@ -15,7 +15,12 @@ public enum ErrorLevel { this.color = color; } - public boolean isInErrorMinimum(@NotNull ErrorLevel minimum) { + /** + * Returns whether this error level is under the given minimum. + * @param minimum The minimum to check against. + * @return Whether this error level is under the given minimum. + */ + public boolean isInMinimum(@NotNull ErrorLevel minimum) { return this.ordinal() <= minimum.ordinal(); } } diff --git a/src/main/java/lanat/TupleCharacter.java b/src/main/java/lanat/TupleChar.java similarity index 85% rename from src/main/java/lanat/TupleCharacter.java rename to src/main/java/lanat/TupleChar.java index ded66b5e..b367e8c4 100644 --- a/src/main/java/lanat/TupleCharacter.java +++ b/src/main/java/lanat/TupleChar.java @@ -7,7 +7,7 @@ * Changing the tuple characters may break compatibility with shells that use the same characters. *

*/ -public enum TupleCharacter { +public enum TupleChar { SQUARE_BRACKETS('[', ']'), PARENTHESIS('(', ')'), BRACES('{', '}'), @@ -15,7 +15,7 @@ public enum TupleCharacter { public final char open, close; - TupleCharacter(char open, char close) { + TupleChar(char open, char close) { this.open = open; this.close = close; } diff --git a/src/main/java/lanat/argumentTypes/EnumArgumentType.java b/src/main/java/lanat/argumentTypes/EnumArgumentType.java index cb90d941..5be6dd14 100644 --- a/src/main/java/lanat/argumentTypes/EnumArgumentType.java +++ b/src/main/java/lanat/argumentTypes/EnumArgumentType.java @@ -7,7 +7,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.Arrays; +import java.util.stream.Stream; /** * An argument type that takes an enum value. @@ -65,7 +65,7 @@ public T parseValues(@NotNull String @NotNull [] args) { @Override public @Nullable String getDescription() { return "Specify one of the following values (case is ignored): " - + String.join(", ", Arrays.stream(this.values).map(Enum::name).toList()) + + String.join(", ", Stream.of(this.values).map(Enum::name).toList()) + ". Default is " + this.getInitialValue().name() + "."; } } diff --git a/src/main/java/lanat/argumentTypes/TryParseArgumentType.java b/src/main/java/lanat/argumentTypes/TryParseArgumentType.java index f4ba5747..129cc580 100644 --- a/src/main/java/lanat/argumentTypes/TryParseArgumentType.java +++ b/src/main/java/lanat/argumentTypes/TryParseArgumentType.java @@ -24,7 +24,7 @@ public class TryParseArgumentType extends ArgumentType { private final Function parseMethod; private final @NotNull Class type; - private static final String[] TRY_PARSE_METHOD_NAMES = new String[] { "valueOf", "from", "parse" }; + private static final String[] TRY_PARSE_METHOD_NAMES = { "valueOf", "from", "parse" }; public TryParseArgumentType(@NotNull Class type) { @@ -45,8 +45,7 @@ private static boolean isValidExecutable(Executable executable) { } private boolean isValidMethod(Method method) { - return TryParseArgumentType.isValidExecutable(method) - && method.getReturnType() == this.type; + return method.getReturnType() == this.type && TryParseArgumentType.isValidExecutable(method); } @@ -74,18 +73,20 @@ protected void addError(@NotNull String value) { } // Otherwise, try to find a constructor that takes a string. - final var ctor = Stream.of(this.type.getConstructors()) + return Stream.of(this.type.getConstructors()) .filter(TryParseArgumentType::isValidExecutable) - .findFirst(); - - return ctor.>map(tConstructor -> input -> { - try { - return tConstructor.newInstance(input); - } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { - this.addError(input); - } - return null; - }).orElse(null); + .findFirst() + .>map(c -> input -> { + try { + return c.newInstance(input); + } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { + this.addError( + "Unable to instantiate type '" + this.type.getSimpleName() + "' with value '" + input + "'." + ); + } + return null; + }) + .orElse(null); } @Override diff --git a/src/main/java/lanat/errorFormatterGenerators/Pretty.java b/src/main/java/lanat/errorFormatterGenerators/Pretty.java index 660c18a9..e42531e7 100644 --- a/src/main/java/lanat/errorFormatterGenerators/Pretty.java +++ b/src/main/java/lanat/errorFormatterGenerators/Pretty.java @@ -1,63 +1,80 @@ package lanat.errorFormatterGenerators; import lanat.ErrorFormatter; +import lanat.utils.Range; import lanat.utils.UtlString; import lanat.utils.displayFormatter.FormatOption; import lanat.utils.displayFormatter.TextFormatter; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; +import java.util.List; public class Pretty extends ErrorFormatter.Generator { @Override public @NotNull String generate() { - // first figure out the length of the longest line - final var maxLength = UtlString.getLongestLine(this.getContents()).length(); + final var contents = this.getContentsWrapped(); final var formatter = this.getErrorLevelFormatter(); final String tokensFormatting = this.getTokensView(); + final var longestLineLength = UtlString.getLongestLine(contents).length(); + return formatter.withContents(" ┌─%s".formatted(this.getErrorLevel())).toString() // only add a new line if there are tokens to display + (tokensFormatting.isEmpty() ? "" : "\n" + tokensFormatting) // first insert a vertical bar at the start of each line - + this.getContentsWrapped().replaceAll("^|\\n", formatter.withContents("\n │ ").toString()) + + contents.replaceAll("^|\\n", formatter.withContents("\n │ ").toString()) // then insert a horizontal bar at the end, with the length of the longest line approximately - + formatter.withContents("\n └" + "─".repeat(Math.max(maxLength - 5, 0)) + " ───── ── ─") + + formatter.withContents("\n └" + "─".repeat(Math.max(longestLineLength - 5, 0)) + " ───── ── ─") + '\n'; } @Override protected @NotNull String generateTokensView(@NotNull ErrorFormatter.DisplayTokensOptions options) { - final var arrow = TextFormatter.ERROR("<-").withForegroundColor(this.getErrorLevel().color); final var tokensFormatters = new ArrayList<>(this.getTokensFormatters()); final int tokensLength = tokensFormatters.size(); final var tokensRange = options.tokensRange(); // add an arrow at the start or end if the index is out of bounds - if (tokensRange.start() < 0) { - tokensFormatters.add(0, arrow); - } else if (tokensRange.start() >= tokensLength) { - tokensFormatters.add(arrow); - } + if (options.showArrows() || !TextFormatter.enableSequences) + this.putArrows(tokensFormatters, tokensRange); + else + this.highlightTokens(tokensFormatters, tokensRange); + for (int i = 0; i < tokensLength; i++) { // dim tokens before the command if (i < this.getAbsoluteCmdTokenIndex()) { tokensFormatters.get(i).addFormat(FormatOption.DIM); } - - // highlight tokens in the range - if (i >= tokensRange.start() && i < tokensRange.end() + 1) { - if (options.placeArrow()) { - tokensFormatters.add(i, arrow); - } else { - tokensFormatters.get(i) - .withForegroundColor(this.getErrorLevel().color) - .addFormat(FormatOption.REVERSE, FormatOption.BOLD); - } - } } return String.join(" ", tokensFormatters.stream().map(TextFormatter::toString).toList()); } + + private void highlightTokens(@NotNull List tokensFormatters, @NotNull Range range) { + for (int i = range.start(); i <= range.end(); i++) { + tokensFormatters.get(i) + .withForegroundColor(this.getErrorLevel().color) + .addFormat(FormatOption.REVERSE, FormatOption.BOLD); + } + } + + private void putArrows(@NotNull List tokensFormatters, @NotNull Range range) { + if (!range.isRange()) { + if (range.start() >= tokensFormatters.size()) + tokensFormatters.add(this.getArrow(true)); + else + tokensFormatters.add(range.start() + 1, this.getArrow(true)); + return; + } + + tokensFormatters.add(range.end() + 1, this.getArrow(true)); + tokensFormatters.add(range.start(), this.getArrow(false)); + } + + private @NotNull TextFormatter getArrow(boolean isLeft) { + return new TextFormatter(isLeft ? "<-" : "->", this.getErrorLevel().color) + .addFormat(FormatOption.REVERSE, FormatOption.BOLD); + } } diff --git a/src/main/java/lanat/parsing/Parser.java b/src/main/java/lanat/parsing/Parser.java index ea05bc96..5b76f61e 100644 --- a/src/main/java/lanat/parsing/Parser.java +++ b/src/main/java/lanat/parsing/Parser.java @@ -30,6 +30,11 @@ public class Parser extends ParsingStateBase { */ private short currentTokenIndex = 0; + /** + * Whether we are currently parsing values in a tuple. + */ + private boolean isInTuple = false; + /** * The parsed arguments. This is a map of the argument to the value that it parsed. The reason this is saved is that * we don't want to run {@link Parser#getParsedArgumentsHashMap()} multiple times because that can break stuff badly @@ -58,6 +63,12 @@ public boolean hasDisplayErrors() { return this.getErrorsInLevelMinimum(this.customErrors, true); } + @Override + public void addError(@NotNull ParseError error) { + error.setIsInTuple(this.isInTuple); // set whether the error was caused while parsing values in a tuple + super.addError(error); + } + public void addError(@NotNull ParseError.ParseErrorType type, @Nullable Argument arg, int argValueCount, int currentIndex) { this.addError(new ParseError(type, currentIndex, arg, argValueCount)); } @@ -150,12 +161,12 @@ private void executeArgParse(@NotNull Argument arg) { return; } - final boolean isInTuple = ( + this.isInTuple = ( this.currentTokenIndex < this.tokens.size() && this.tokens.get(this.currentTokenIndex).type() == TokenType.ARGUMENT_VALUE_TUPLE_START ); - final byte ifTupleOffset = (byte)(isInTuple ? 1 : 0); + final byte ifTupleOffset = (byte)(this.isInTuple ? 1 : 0); final ArrayList values = new ArrayList<>(); short numValues = 0; @@ -167,7 +178,7 @@ private void executeArgParse(@NotNull Argument arg) { numValues++, tokenIndex++ ) { final Token currentToken = this.tokens.get(tokenIndex); - if (!isInTuple && ( + if (!this.isInTuple && ( currentToken.type().isArgumentSpecifier() || numValues >= argNumValuesRange.end() ) || currentToken.type().isTuple() @@ -179,7 +190,7 @@ private void executeArgParse(@NotNull Argument arg) { final int skipIndexCount = numValues + ifTupleOffset*2; if (numValues > argNumValuesRange.end() || numValues < argNumValuesRange.start()) { - this.addError(ParseError.ParseErrorType.ARG_INCORRECT_VALUE_NUMBER, arg, numValues + ifTupleOffset); + this.addError(ParseError.ParseErrorType.ARG_INCORRECT_VALUE_NUMBER, arg, numValues); this.currentTokenIndex += skipIndexCount; return; } @@ -202,11 +213,6 @@ private void executeArgParse(@NotNull Argument arg) { private void executeArgParse(@NotNull Argument arg, @Nullable String value) { final Range argumentValuesRange = arg.argType.getRequiredArgValueCount(); - if (value == null || value.isEmpty()) { - this.executeArgParse(arg); // value is not present in the suffix of the argList. Continue parsing values. - return; - } - // just skip the whole thing if it doesn't need any values if (argumentValuesRange.isZero()) { arg.parseValues(this.currentTokenIndex); @@ -214,10 +220,24 @@ private void executeArgParse(@NotNull Argument arg, @Nullable String value } if (argumentValuesRange.start() > 1) { - this.addError(ParseError.ParseErrorType.ARG_INCORRECT_VALUE_NUMBER, arg, 0); + this.addError( + new ParseError( + ParseError.ParseErrorType.ARG_INCORRECT_VALUE_NUMBER, + this.currentTokenIndex + 1, + arg, + 1 + ) {{ + this.setIsInArgNameList(true); // set that the error was caused by an argument name list + }} + ); return; } + if (value == null || value.isEmpty()) { + this.executeArgParse(arg); // value is not present in the suffix of the argList. Continue parsing values. + return; + } + // pass the arg values to the argument subParser arg.parseValues(this.currentTokenIndex, value); } @@ -226,42 +246,54 @@ private void executeArgParse(@NotNull Argument arg, @Nullable String value * Parses the given string as a list of single-char argument names. */ private void parseArgNameList(@NotNull String args) { + var doSkipToken = true; // atomic because we need to modify it in the lambda + Argument lastArgument = null; + // its multiple of them. We can only do this with arguments that accept 0 values. for (short i = 0; i < args.length(); i++) { - final short constIndex = i; // this is because the lambda requires the variable to be final - - if (!this.runForArgument(args.charAt(i), a -> { - // if the argument accepts 0 values, then we can just parse it like normal - if (a.argType.getRequiredArgValueCount().isZero()) { - this.executeArgParse(a); - - // -- arguments now may accept 1 or more values from now on: - - // if this argument is the last one in the list, then we can parse the next values after it - } else if (constIndex == args.length() - 1) { - this.currentTokenIndex++; - this.executeArgParse(a); - - // if this argument is not the last one in the list, then we can parse the rest of the chars as the value - } else { - this.executeArgParse(a, args.substring(constIndex + 1)); - } - })) - return; + var argument = this.getArgument(args.charAt(i)); + + if (argument == null) { + this.addError( + ParseError.ParseErrorType.UNMATCHED_IN_ARG_NAME_LIST, + lastArgument, + i + 1, // substr for the current token + this.currentTokenIndex + 1 // the next token is the one that caused the error + ); + break; + } + + // if the argument accepts 0 values, then we can just parse it like normal + if (argument.argType.getRequiredArgValueCount().isZero()) { + this.executeArgParse(argument); + + // -- arguments now may accept 1 or more values from now on: + + // if this argument is the last one in the list, then we can parse the next values after it + } else if (i == args.length() - 1) { + this.currentTokenIndex++; + this.executeArgParse(argument); + doSkipToken = false; // we don't want to skip the next token because executeArgParse already did that + + // if this argument is not the last one in the list, then we can parse the rest of the chars as the value + } else { + this.executeArgParse(argument, args.substring(i + 1)); + } + + lastArgument = argument; } - this.currentTokenIndex++; + + if (doSkipToken) this.currentTokenIndex++; } /** Returns the positional argument at the given index of declaration. */ private @Nullable Argument getArgumentByPositionalIndex(short index) { - final var posArgs = this.command.getPositionalArguments(); + var posArgs = this.command.getPositionalArguments(); - for (short i = 0; i < posArgs.size(); i++) { - if (i == index) { - return posArgs.get(i); - } - } - return null; + if (index >= posArgs.size()) + return null; + + return posArgs.get(index); } /** diff --git a/src/main/java/lanat/parsing/ParsingStateBase.java b/src/main/java/lanat/parsing/ParsingStateBase.java index 12e5cd13..59d55bb4 100644 --- a/src/main/java/lanat/parsing/ParsingStateBase.java +++ b/src/main/java/lanat/parsing/ParsingStateBase.java @@ -5,6 +5,7 @@ import lanat.utils.ErrorLevelProvider; import lanat.utils.ErrorsContainerImpl; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.function.Consumer; @@ -22,21 +23,20 @@ public ParsingStateBase(@NotNull Command command) { /** * Executes a callback for the argument found by the name specified. * - * @return ParseErrorType.ArgumentNotFound if an argument was found + * @return {@code true} if an argument was found */ protected boolean runForArgument(@NotNull String argName, @NotNull Consumer<@NotNull Argument> f) { - for (final var argument : this.getArguments()) { - if (argument.checkMatch(argName)) { - f.accept(argument); - return true; - } + var arg = this.getArgument(argName); + if (arg != null) { + f.accept(arg); + return true; } return false; } /** - * Executes a callback for the argument found by the name specified. + * Executes a callback for the argument found by the single character name specified. * * @return {@code true} if an argument was found */ @@ -47,13 +47,40 @@ protected boolean runForArgument(@NotNull String argName, @NotNull Consumer<@Not * I don't really want to make "checkMatch" have different behavior depending on the length of the string, so * an overload seems better. */ protected boolean runForArgument(char argName, @NotNull Consumer<@NotNull Argument> f) { + var arg = this.getArgument(argName); + if (arg != null) { + f.accept(arg); + return true; + } + return false; + } + + /** + * Returns the argument found by the single character name specified. + * @param argName the name of the argument to find + * @return the argument found, or {@code null} if no argument was found + */ + protected @Nullable Argument getArgument(char argName) { for (final var argument : this.getArguments()) { if (argument.checkMatch(argName)) { - f.accept(argument); - return true; + return argument; } } - return false; + return null; + } + + /** + * Returns the argument found by the name specified. + * @param argName the name of the argument to find + * @return the argument found, or {@code null} if no argument was found + */ + protected @Nullable Argument getArgument(String argName) { + for (final var argument : this.getArguments()) { + if (argument.checkMatch(argName)) { + return argument; + } + } + return null; } protected @NotNull List<@NotNull Argument> getArguments() { diff --git a/src/main/java/lanat/parsing/Tokenizer.java b/src/main/java/lanat/parsing/Tokenizer.java index 0397ac40..9d827d91 100644 --- a/src/main/java/lanat/parsing/Tokenizer.java +++ b/src/main/java/lanat/parsing/Tokenizer.java @@ -1,12 +1,16 @@ package lanat.parsing; +import lanat.Argument; import lanat.Command; import lanat.parsing.errors.TokenizeError; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.stream.Stream; public class Tokenizer extends ParsingStateBase { /** Are we currently within a tuple? */ @@ -38,11 +42,6 @@ public Tokenizer(@NotNull Command command) { super(command); } - // ------------------------------------------------ Error Handling ------------------------------------------------ - void addError(@NotNull TokenizeError.TokenizeErrorType type, int index) { - this.addError(new TokenizeError(type, index)); - } - // ------------------------------------------------ ////////////// ------------------------------------------------ private void setInputString(@NotNull String inputString) { this.inputString = inputString; @@ -65,7 +64,6 @@ public void tokenize(@NotNull String input) { } char currentStringChar = 0; // the character that opened the string - TokenizeError.TokenizeErrorType errorType = null; for ( this.currentCharIndex = 0; @@ -79,7 +77,7 @@ public void tokenize(@NotNull String input) { this.currentValue.append(this.inputChars[++this.currentCharIndex]); // skip the \ character and append the next character // reached a possible value wrapped in quotes - } else if (cChar == '"' || cChar == '\'') { + } else if ((cChar == '"' || cChar == '\'')) { // if we are already in an open string, push the current value and close the string. Make sure // that the current char is the same as the one that opened the string if (this.stringOpen && currentStringChar == cChar) { @@ -87,8 +85,9 @@ public void tokenize(@NotNull String input) { this.currentValue.setLength(0); this.stringOpen = false; - // the string is open, but the character does not match. Push it as a normal character - } else if (this.stringOpen) { + // the string is open, but the character does not match, or there's something already in the current value. + // Push it as a normal character + } else if (this.stringOpen || !this.currentValue.isEmpty()) { this.currentValue.append(cChar); // the string is not open, so open it and set the current string char to the current char @@ -103,15 +102,17 @@ public void tokenize(@NotNull String input) { // reached a possible tuple start character } else if (cChar == this.tupleOpenChar) { - // if we are already in a tuple, set error and stop tokenizing + // if we are already in a tuple, add error if (this.tupleOpen) { - errorType = TokenizeError.TokenizeErrorType.TUPLE_ALREADY_OPEN; - break; + // push tuple start token so the user can see the incorrect tuple char + this.addToken(TokenType.ARGUMENT_VALUE_TUPLE_START, this.tupleOpenChar); + this.addError(TokenizeError.TokenizeErrorType.TUPLE_ALREADY_OPEN); + continue; } else if (!this.currentValue.isEmpty()) { // if there was something before the tuple, tokenize it this.tokenizeCurrentValue(); } - // push the tuple token and set the state to tuple open + // set the state to tuple open this.addToken(TokenType.ARGUMENT_VALUE_TUPLE_START, this.tupleOpenChar); this.tupleOpen = true; @@ -119,8 +120,10 @@ public void tokenize(@NotNull String input) { } else if (cChar == this.tupleCloseChar) { // if we are not in a tuple, set error and stop tokenizing if (!this.tupleOpen) { - errorType = TokenizeError.TokenizeErrorType.UNEXPECTED_TUPLE_CLOSE; - break; + // push tuple start token so the user can see the incorrect tuple char + this.addToken(TokenType.ARGUMENT_VALUE_TUPLE_END, this.tupleCloseChar); + this.addError(TokenizeError.TokenizeErrorType.UNEXPECTED_TUPLE_CLOSE); + continue; } // if there was something before the tuple, tokenize it @@ -128,7 +131,7 @@ public void tokenize(@NotNull String input) { this.addToken(TokenType.ARGUMENT_VALUE, this.currentValue.toString()); } - // push the tuple token and set the state to tuple closed + // set the state to tuple closed this.addToken(TokenType.ARGUMENT_VALUE_TUPLE_END, this.tupleCloseChar); this.currentValue.setLength(0); this.tupleOpen = false; @@ -158,22 +161,16 @@ public void tokenize(@NotNull String input) { } } - if (errorType == null) - if (this.tupleOpen) { - errorType = TokenizeError.TokenizeErrorType.TUPLE_NOT_CLOSED; - } else if (this.stringOpen) { - errorType = TokenizeError.TokenizeErrorType.STRING_NOT_CLOSED; - } + if (this.tupleOpen) + this.addError(TokenizeError.TokenizeErrorType.TUPLE_NOT_CLOSED); + if (this.stringOpen) + this.addError(TokenizeError.TokenizeErrorType.STRING_NOT_CLOSED); // we left something in the current value, tokenize it if (!this.currentValue.isEmpty()) { this.tokenizeCurrentValue(); } - if (errorType != null) { - this.addError(errorType, this.finalTokens.size()); - } - this.hasFinished = true; } @@ -203,6 +200,7 @@ private void addToken(@NotNull TokenType type, char contents) { } else if (this.isSubCommand(str)) { type = TokenType.COMMAND; } else { + this.checkForSimilar(str); type = TokenType.ARGUMENT_VALUE; } @@ -217,15 +215,20 @@ private void addToken(@NotNull TokenType type, char contents) { */ private void tokenizeCurrentValue() { final Token token = this.tokenizeWord(this.currentValue.toString()); - Command subCmd; + // if this is a Sub-Command, continue tokenizing next elements - if (token.type() == TokenType.COMMAND && (subCmd = this.getSubCommandByName(token.contents())) != null) { + if (token.type() == TokenType.COMMAND) { // forward the rest of stuff to the Sub-Command - subCmd.getTokenizer().tokenize(this.inputString.substring(this.currentCharIndex)); + this.getSubCommandByName(token.contents()) + .getTokenizer() + .tokenize(this.inputString.substring(this.currentCharIndex)); + this.hasFinished = true; } else { + // otherwise, just add the token to the final tokens list this.finalTokens.add(token); } + this.currentValue.setLength(0); } @@ -239,17 +242,26 @@ private void tokenizeCurrentValue() { *

*/ private boolean isArgNameList(@NotNull String str) { - if (str.length() < 2) return false; - - final var possiblePrefixes = new ArrayList(); - final var charArray = str.substring(1).toCharArray(); - - for (final char argName : charArray) { - if (!this.runForArgument(argName, a -> possiblePrefixes.add(a.getPrefix().character))) + if (str.length() < 2 || !Character.isAlphabetic(str.charAt(1))) return false; + + // store the possible prefixes. Start with the common ones (single and double dash) + // We add the common prefixes because it can be confusing for the user to have to put a specific prefix + // used by any argument in the name list + final var possiblePrefixes = new HashSet<>(Arrays.asList(Argument.PrefixChar.COMMON_PREFIXES)); + int foundArgs = 0; // how many characters in the string are valid arguments + + // iterate over the characters in the string, starting from the second one (the first one is the prefix) + for (final char argName : str.substring(1).toCharArray()) { + // if an argument is found with that char name, append its prefix to the possible prefixes + // and increment the foundArgs counter. + // If no argument is found, stop checking + if (!this.runForArgument(argName, argument -> possiblePrefixes.add(argument.getPrefix()))) break; + foundArgs++; } - return possiblePrefixes.size() >= 1 && possiblePrefixes.contains(str.charAt(0)); + // if there's at least one argument and the first character is a valid prefix, return true + return foundArgs >= 1 && possiblePrefixes.stream().anyMatch(p -> p.character == str.charAt(0)); } /** @@ -276,6 +288,35 @@ private boolean isSubCommand(@NotNull String str) { return this.getCommands().stream().anyMatch(c -> c.hasName(str)); } + /** + * Checks if the given string is similar to any of the argument names. + *

+ * If so, adds an error to the error list. + * @param str The string to check. + */ + private void checkForSimilar(@NotNull String str) { + // if the string is too short, don't bother checking + if (str.length() < 2) return; + + // check for the common prefixes + Stream.of(Argument.PrefixChar.COMMON_PREFIXES) + .map(c -> c.character) + .forEach(checkPrefix -> { + // if not present, don't bother checking + if (str.charAt(0) != checkPrefix) return; + + // get rid of the prefix (single or double) + final var nameToCheck = str.substring(str.charAt(1) == checkPrefix ? 2 : 1); + + for (var arg : this.getArguments()) { + if (!arg.hasName(nameToCheck)) continue; + + // offset 1 because this is called before a token is pushed + this.addError(TokenizeError.TokenizeErrorType.SIMILAR_ARGUMENT, arg, 1); + } + }); + } + /** * Returns {@code true} if the character of {@link Tokenizer#inputChars} at a relative index from * {@link Tokenizer#currentCharIndex} is equal to the specified character. @@ -290,9 +331,8 @@ private boolean isCharAtRelativeIndex(int index, char character) { } /** Returns a command from the Sub-Commands of {@link Tokenizer#command} that matches the given name */ - private Command getSubCommandByName(@NotNull String name) { - var x = this.getCommands().stream().filter(sc -> sc.hasName(name)).toList(); - return x.isEmpty() ? null : x.get(0); + private @NotNull Command getSubCommandByName(@NotNull String name) { + return this.command.getCommand(name); } /** @@ -329,4 +369,25 @@ private Command getSubCommandByName(@NotNull String name) { public boolean isFinishedTokenizing() { return this.hasFinished; } + + + // ------------------------------------------------ Error Handling ------------------------------------------------ + + /** + * Inserts an error at the current token index with the given type. + * @param type The type of the error to insert. + */ + private void addError(@NotNull TokenizeError.TokenizeErrorType type) { + this.addError(type, null, 0); + } + + /** + * Inserts an error at the current token index with the given type and argument. + * @param type The type of the error to insert. + * @param argument The argument that caused the error. + * @param indexOffset The offset from the current token index to the token that caused the error. + */ + private void addError(@NotNull TokenizeError.TokenizeErrorType type, @Nullable Argument argument, int indexOffset) { + this.addError(new TokenizeError(type, this.finalTokens.size() + indexOffset, argument)); + } } diff --git a/src/main/java/lanat/parsing/errors/ErrorHandler.java b/src/main/java/lanat/parsing/errors/ErrorHandler.java index d3f39f48..fe6ee3c9 100644 --- a/src/main/java/lanat/parsing/errors/ErrorHandler.java +++ b/src/main/java/lanat/parsing/errors/ErrorHandler.java @@ -47,7 +47,7 @@ public ErrorHandler(@NotNull Command rootCommand) { this.addAll(cmd.getErrorsUnderDisplayLevel()); this.addAll(cmd.getTokenizer().getErrorsUnderDisplayLevel()); this.addAll(cmd.getParser().getCustomErrors()); - this.addAll(ParseError.filter(cmd.getParser().getErrorsUnderDisplayLevel())); + this.addAll(cmd.getParser().getErrorsUnderDisplayLevel()); }}.stream() .sorted(Comparator.comparingInt(x -> x.tokenIndex)) // sort them by their token index... .forEach(e -> errors.add(e.handle(this))); // ...and handle them diff --git a/src/main/java/lanat/parsing/errors/ParseError.java b/src/main/java/lanat/parsing/errors/ParseError.java index 77f756cc..ee0ba55b 100644 --- a/src/main/java/lanat/parsing/errors/ParseError.java +++ b/src/main/java/lanat/parsing/errors/ParseError.java @@ -9,23 +9,23 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.List; - @SuppressWarnings("unused") public class ParseError extends ParseStateErrorBase { - public final Argument argument; - public final int valueCount; + public final @Nullable Argument argument; + private final int valueCount; + private boolean isInTuple = false; + private boolean isInArgNameList = false; private ArgumentGroup argumentGroup; public enum ParseErrorType implements ErrorLevelProvider { REQUIRED_ARGUMENT_NOT_USED, UNMATCHED_TOKEN(ErrorLevel.WARNING), + UNMATCHED_IN_ARG_NAME_LIST(ErrorLevel.WARNING), ARG_INCORRECT_VALUE_NUMBER, ARG_INCORRECT_USAGES_COUNT, MULTIPLE_ARGS_IN_EXCLUSIVE_GROUP_USED; - public final @NotNull ErrorLevel level; + private final @NotNull ErrorLevel level; ParseErrorType() { this.level = ErrorLevel.ERROR; @@ -51,37 +51,55 @@ public void setArgumentGroup(@NotNull ArgumentGroup argumentGroup) { this.argumentGroup = argumentGroup; } - public static @NotNull List<@NotNull ParseError> filter(@NotNull List<@NotNull ParseError> errors) { - final var newList = new ArrayList<>(errors); - - for (final var err : errors) { - /* if we are going to show an error about an argument being incorrectly used, and that argument is defined - * as required, we don't need to show the required error since its obvious that the user knows that - * the argument is required */ - if (err.errorType == ParseErrorType.ARG_INCORRECT_VALUE_NUMBER) { - newList.removeIf(e -> - e.argument != null - && e.argument.equals(err.argument) - && e.errorType == ParseErrorType.REQUIRED_ARGUMENT_NOT_USED - ); - } - } + /** + * Sets whether the error was caused while parsing values in a tuple. + * @param isInTuple whether the error was caused while parsing values in a tuple + */ + public void setIsInTuple(boolean isInTuple) { + this.isInTuple = isInTuple; + } + + /** + * Sets whether the error was caused by an argument name list. + * @param isInArgNameList whether the error was caused by an argument name list + */ + public void setIsInArgNameList(boolean isInArgNameList) { + this.isInArgNameList = isInArgNameList; + } - return newList; + /** + * Returns the offset from the token index to the value tokens. Adds 2 if the error was caused while parsing values + * in a tuple. + * @return the offset from the token index to the value tokens + */ + private int getValueTokensOffset() { + return this.valueCount + (this.isInTuple ? 2 : 0); // 2 for the tuple tokens } @Handler("ARG_INCORRECT_VALUE_NUMBER") protected void handleIncorrectValueNumber() { assert this.argument != null; + // offset to just show the value tokens (we don't want to highlight the argument token as well) + final var inTupleOffset = this.isInTuple ? 1 : 0; + this.fmt() .setContent("Incorrect number of values for argument '%s'.%nExpected %s, but got %d." .formatted( this.argument.getName(), this.argument.argType.getRequiredArgValueCount().getMessage("value"), - Math.max(this.valueCount - 1, 0) // this is done because if there are tuples, the end token is counted as a value (maybe a bit hacky?) + this.valueCount ) - ) - .displayTokens(this.tokenIndex + 1, this.valueCount, this.valueCount == 0); + ); + + if (this.isInArgNameList) + // special case for when the error is caused by an argument name list + this.fmt().displayTokens(this.tokenIndex, 0, false); + else + this.fmt().displayTokens( + this.tokenIndex + inTupleOffset, + this.getValueTokensOffset() - inTupleOffset, + this.getValueTokensOffset() == 0 + ); } @Handler("ARG_INCORRECT_USAGES_COUNT") @@ -95,7 +113,7 @@ protected void handleIncorrectUsagesCount() { UtlString.plural("time", this.argument.getUsageCount()) ) ) - .displayTokens(this.tokenIndex + 1, this.valueCount, this.valueCount == 0); + .displayTokens(this.tokenIndex, this.getValueTokensOffset(), false); } @Handler("REQUIRED_ARGUMENT_NOT_USED") @@ -106,27 +124,40 @@ protected void handleRequiredArgumentNotUsed() { this.fmt() .setContent( argCmd instanceof ArgumentParser - ? "Required argument '%s' not used.".formatted(this.argument.getName()) + ? "Required argument '" + this.argument.getName() + "' not used." : "Required argument '%s' for command '%s' not used.".formatted(this.argument.getName(), argCmd.getName()) ) - .displayTokens(this.tokenIndex + 1); + .displayTokens(this.tokenIndex); } @Handler("UNMATCHED_TOKEN") protected void handleUnmatchedToken() { this.fmt() - .setContent("Token '%s' does not correspond with a valid argument, value, or command." - .formatted(this.getCurrentToken().contents()) + .setContent( + "Token '" + + this.getCurrentToken().contents() + + "' does not correspond with a valid argument, argument list, value, or command." + ) + .displayTokens(this.tokenIndex, 0, false); + } + + // here we use valueCount as the offset to the unmatched token, to substr the token contents + @Handler("UNMATCHED_IN_ARG_NAME_LIST") + protected void handleUnmatchedInArgNameList() { + assert this.argument != null; + + this.fmt() + .setContent( + "Argument '" + this.argument.getName() + "' does not take any values, but got '" + + this.getCurrentToken().contents().substring(this.valueCount) + "'." ) - .displayTokens(this.tokenIndex, this.valueCount, false); + .displayTokens(this.tokenIndex, 0, false); } @Handler("MULTIPLE_ARGS_IN_EXCLUSIVE_GROUP_USED") protected void handleMultipleArgsInExclusiveGroupUsed() { this.fmt() - .setContent("Multiple arguments in exclusive group '%s' used." - .formatted(this.argumentGroup.getName()) - ) - .displayTokens(this.tokenIndex, this.valueCount, false); + .setContent("Multiple arguments in exclusive group '" + this.argumentGroup.getName() + "' used.") + .displayTokens(this.tokenIndex, this.getValueTokensOffset(), false); } } diff --git a/src/main/java/lanat/parsing/errors/TokenizeError.java b/src/main/java/lanat/parsing/errors/TokenizeError.java index 9122be83..cbb07091 100644 --- a/src/main/java/lanat/parsing/errors/TokenizeError.java +++ b/src/main/java/lanat/parsing/errors/TokenizeError.java @@ -1,32 +1,48 @@ package lanat.parsing.errors; +import lanat.Argument; import lanat.ErrorLevel; import lanat.utils.ErrorLevelProvider; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @SuppressWarnings("unused") public class TokenizeError extends ParseStateErrorBase { + private final @Nullable Argument argument; + public enum TokenizeErrorType implements ErrorLevelProvider { TUPLE_ALREADY_OPEN, UNEXPECTED_TUPLE_CLOSE, TUPLE_NOT_CLOSED, - STRING_NOT_CLOSED; + STRING_NOT_CLOSED, + SIMILAR_ARGUMENT(ErrorLevel.INFO); + + private final @NotNull ErrorLevel level; + + TokenizeErrorType() { + this.level = ErrorLevel.ERROR; + } + + TokenizeErrorType(@NotNull ErrorLevel level) { + this.level = level; + } @Override public @NotNull ErrorLevel getErrorLevel() { - return ErrorLevel.ERROR; + return this.level; } } - public TokenizeError(@NotNull TokenizeErrorType type, int index) { + public TokenizeError(@NotNull TokenizeErrorType type, int index, @Nullable Argument argument) { super(type, index); + this.argument = argument; } @Handler("TUPLE_ALREADY_OPEN") protected void handleTupleAlreadyOpen() { this.fmt() .setContent("Tuple already open.") - .displayTokens(this.tokenIndex + 1); + .displayTokens(this.tokenIndex, 0, false); } @Handler("TUPLE_NOT_CLOSED") @@ -40,7 +56,7 @@ protected void handleTupleNotClosed() { protected void handleUnexpectedTupleClose() { this.fmt() .setContent("Unexpected tuple close.") - .displayTokens(this.tokenIndex + 1); + .displayTokens(this.tokenIndex, 0, false); } @Handler("STRING_NOT_CLOSED") @@ -49,4 +65,17 @@ protected void handleStringNotClosed() { .setContent("String not closed.") .displayTokens(this.tokenIndex + 1); } + + @Handler("SIMILAR_ARGUMENT") + protected void handleSimilarArgument() { + assert this.argument != null; + + this.fmt() + .setContent( + "Found argument with name given, but with a different prefix (" + + this.argument.getPrefix().character + + ")." + ) + .displayTokens(this.tokenIndex, 0, false); + } } diff --git a/src/main/java/lanat/utils/ErrorsContainerImpl.java b/src/main/java/lanat/utils/ErrorsContainerImpl.java index fb9c6be9..cae4c1f0 100644 --- a/src/main/java/lanat/utils/ErrorsContainerImpl.java +++ b/src/main/java/lanat/utils/ErrorsContainerImpl.java @@ -13,11 +13,14 @@ * @param The type of the errors to store. */ public abstract class ErrorsContainerImpl implements ErrorsContainer { - private @NotNull ModifyRecord minimumExitErrorLevel = ModifyRecord.of(ErrorLevel.ERROR); - private @NotNull ModifyRecord minimumDisplayErrorLevel = ModifyRecord.of(ErrorLevel.INFO); + private final ModifyRecord minimumExitErrorLevel; + private final ModifyRecord minimumDisplayErrorLevel; private final @NotNull List errors = new ArrayList<>(); - public ErrorsContainerImpl() {} + public ErrorsContainerImpl() { + // default values + this(ModifyRecord.of(ErrorLevel.ERROR), ModifyRecord.of(ErrorLevel.INFO)); + } public ErrorsContainerImpl( @NotNull ModifyRecord minimumExitErrorLevelRecord, @@ -28,11 +31,6 @@ public ErrorsContainerImpl( this.minimumDisplayErrorLevel = minimumDisplayErrorLevelRecord; } - public ErrorsContainerImpl(@NotNull ErrorLevel minimumExitErrorLevel, @NotNull ErrorLevel minimumDisplayErrorLevel) { - this.minimumExitErrorLevel.set(minimumExitErrorLevel); - this.minimumDisplayErrorLevel.set(minimumDisplayErrorLevel); - } - /** * Adds an error to the list of errors. * @@ -68,7 +66,7 @@ public boolean hasDisplayErrors() { } private boolean errorIsInMinimumLevel(@NotNull TErr error, boolean isDisplayError) { - return error.getErrorLevel().isInErrorMinimum(( + return error.getErrorLevel().isInMinimum(( isDisplayError ? this.minimumDisplayErrorLevel : this.minimumExitErrorLevel diff --git a/src/main/java/lanat/utils/ModifyRecord.java b/src/main/java/lanat/utils/ModifyRecord.java index a3b406d7..c49d5332 100644 --- a/src/main/java/lanat/utils/ModifyRecord.java +++ b/src/main/java/lanat/utils/ModifyRecord.java @@ -77,9 +77,7 @@ public void setIfNotModified(T value) { * @param value The value to set. */ public void setIfNotModified(@NotNull ModifyRecord value) { - if (!this.modified) { - this.set(value); - } + this.setIfNotModified(value.value); } /** diff --git a/src/main/java/lanat/utils/Range.java b/src/main/java/lanat/utils/Range.java index b7a1395d..9726341a 100644 --- a/src/main/java/lanat/utils/Range.java +++ b/src/main/java/lanat/utils/Range.java @@ -126,6 +126,10 @@ public int end() { * @return {@code true} if the value is in the range */ public boolean isInRange(int value, boolean startInclusive, boolean endInclusive) { + if (!this.isRange()) { + return value == this.start; + } + boolean isInStart = startInclusive ? value >= this.start : value > this.start; diff --git a/src/main/java/lanat/utils/UtlReflection.java b/src/main/java/lanat/utils/UtlReflection.java index bc6800df..75149712 100644 --- a/src/main/java/lanat/utils/UtlReflection.java +++ b/src/main/java/lanat/utils/UtlReflection.java @@ -47,14 +47,18 @@ public static boolean hasParameters(Method method, Class... parameters) { * @return The instantiated class. If the class could not be instantiated, a {@link RuntimeException} is thrown. */ public static T instantiate(Class clazz, Object... args) { + final Class[] parameterTypes = Stream.of(args) + .map(Object::getClass) + .toArray(Class[]::new); + try { - return clazz.getDeclaredConstructor().newInstance(args); + return clazz.getDeclaredConstructor(parameterTypes).newInstance(args); } catch (NoSuchMethodException e) { throw new RuntimeException("Unable to find a public constructor for the class '" + clazz.getName() + """ '. Please, make sure: - - This class has a public constructor with no arguments. (Or no constructor at all) - - This is a static class. (Not an inner class)""" + - This class has a public constructor with the parameters: %s + - This is a static class. (Not an inner class)""".formatted(Arrays.toString(parameterTypes)) ); } catch (IllegalAccessException e) { throw new RuntimeException( diff --git a/src/main/java/lanat/utils/UtlString.java b/src/main/java/lanat/utils/UtlString.java index 0965714c..23d12cb8 100644 --- a/src/main/java/lanat/utils/UtlString.java +++ b/src/main/java/lanat/utils/UtlString.java @@ -4,8 +4,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.Arrays; import java.util.regex.Pattern; +import java.util.stream.Stream; public final class UtlString { private UtlString() {} @@ -29,7 +29,7 @@ private UtlString() {} * Get the longest line from the contents of a string. Lines are separated by newlines. */ public static @NotNull String getLongestLine(@NotNull String str) { - return Arrays.stream(str.split("\n")).min((a, b) -> b.length() - a.length()).orElse(""); + return Stream.of(str.split("\n")).min((a, b) -> b.length() - a.length()).orElse(""); } /** @@ -252,4 +252,21 @@ public static boolean isNullOrEmpty(@Nullable String str) { public static @NotNull String @NotNull [] split(@NotNull String str, char splitter) { return UtlString.split(str, String.valueOf(splitter), -1); } + + /** + * Returns a pair of strings. The first string is the leading whitespace of the string given, and the second string + * is the string given without the leading whitespace. + * @param str the string to split + * @return a pair of strings + */ + public static @NotNull Pair<@NotNull String, @NotNull String> splitAtLeadingWhitespace(@NotNull String str) { + final var buffWhitespace = new StringBuilder(); + + for (char chr : str.toCharArray()) { + if (!Character.isWhitespace(chr)) break; + buffWhitespace.append(chr); + } + + return new Pair<>(buffWhitespace.toString(), str.substring(buffWhitespace.length())); + } } diff --git a/src/main/java/lanat/utils/displayFormatter/Color.java b/src/main/java/lanat/utils/displayFormatter/Color.java index c5d01501..9c36e2d3 100644 --- a/src/main/java/lanat/utils/displayFormatter/Color.java +++ b/src/main/java/lanat/utils/displayFormatter/Color.java @@ -2,8 +2,6 @@ import org.jetbrains.annotations.NotNull; -import java.util.List; - /** * Enumerates the ANSI color codes that a terminal can normally display. */ @@ -60,7 +58,7 @@ public String toString() { /** * Immutable list of all the dark colors. */ - public static final @NotNull List BRIGHT_COLORS = List.of( + public static final @NotNull Color[] BRIGHT_COLORS = { BRIGHT_RED, BRIGHT_GREEN, BRIGHT_YELLOW, @@ -68,12 +66,12 @@ public String toString() { BRIGHT_MAGENTA, BRIGHT_CYAN, BRIGHT_WHITE - ); + }; /** * Immutable list of all the bright colors. */ - public static final @NotNull List DARK_COLORS = List.of( + public static final @NotNull Color[] DARK_COLORS = { RED, GREEN, YELLOW, @@ -81,5 +79,5 @@ public String toString() { MAGENTA, CYAN, WHITE - ); + }; } \ No newline at end of file diff --git a/src/main/java/lanat/utils/displayFormatter/FormatOption.java b/src/main/java/lanat/utils/displayFormatter/FormatOption.java index 4dd14069..450c284f 100644 --- a/src/main/java/lanat/utils/displayFormatter/FormatOption.java +++ b/src/main/java/lanat/utils/displayFormatter/FormatOption.java @@ -12,9 +12,9 @@ public enum FormatOption { /** Resets all formatting. Including colors. */ RESET_ALL(0), BOLD(1), - ITALIC(3), /** Makes the text dimmer. */ DIM(2), + ITALIC(3), UNDERLINE(4), /** Makes the text blink. */ BLINK(5), @@ -54,6 +54,10 @@ public enum FormatOption { */ public @NotNull String reset() { // for some reason, bold is 21 instead of 20 - return TextFormatter.getSequence(this.value + 20 + (this == BOLD ? 1 : 0)); + return TextFormatter.getSequence( + this == RESET_ALL + ? this.value // RESET_ALL should be the same when resetting + : this.value + 20 + (this == BOLD ? 1 : 0) + ); } } \ No newline at end of file diff --git a/src/main/java/lanat/utils/displayFormatter/TextFormatter.java b/src/main/java/lanat/utils/displayFormatter/TextFormatter.java index 5f8958af..4ca9b48f 100644 --- a/src/main/java/lanat/utils/displayFormatter/TextFormatter.java +++ b/src/main/java/lanat/utils/displayFormatter/TextFormatter.java @@ -1,5 +1,6 @@ package lanat.utils.displayFormatter; +import lanat.utils.UtlString; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -23,7 +24,7 @@ public class TextFormatter { /** * When set to {@code true}, the {@link #toString()} method will not add any terminal sequences, but rather - * return the sequences that would be added by marking them as {@code ESC[} + * return the sequences that would be added by marking them as {@code ESC[]} */ public static boolean debug = false; @@ -73,6 +74,15 @@ public TextFormatter addFormat(@NotNull FormatOption... options) { return this; } + /** + * Removes the specified formatting options from the formatter. + * @param options The formatting options to remove. + */ + public TextFormatter removeFormat(@NotNull FormatOption... options) { + this.formatOptions.removeAll(Arrays.asList(options)); + return this; + } + /** * Sets the foreground color of the formatter. * @param foreground The foreground color of the formatter. @@ -144,11 +154,8 @@ public TextFormatter concat(@NotNull String... strings) { * @return {@code true} if the formatter is simple */ public boolean isSimple() { - return ( - this.contents.length() == 0 - || this.formattingNotDefined() - || !enableSequences - ) && this.concatList.size() == 0; // we cant skip if we need to concat stuff! + return (this.contents.length() == 0 || this.formattingNotDefined()) + && this.concatList.size() == 0; // we cant skip if we need to concat stuff! } /** @@ -169,7 +176,7 @@ public boolean formattingNotDefined() { * @return the start sequences to add to the contents of the formatter */ private @NotNull String getStartSequences() { - if (this.formattingNotDefined() || !TextFormatter.enableSequences) return ""; + if (this.formattingNotDefined()) return ""; final var buffer = new StringBuilder(); if (this.foregroundColor != null) @@ -185,7 +192,7 @@ public boolean formattingNotDefined() { } private @NotNull String getEndSequences() { - if (this.formattingNotDefined() || !TextFormatter.enableSequences) return ""; + if (this.formattingNotDefined()) return ""; final var buffer = new StringBuilder(); if (this.backgroundColor != null) { @@ -245,22 +252,56 @@ public boolean formattingNotDefined() { */ @Override public @NotNull String toString() { - if (this.isSimple()) { + if (!TextFormatter.enableSequences || this.isSimple()) { return this.contents; } - final var buffer = new StringBuilder(); + final var buff = new StringBuilder(); - buffer.append(this.getStartSequences()); - buffer.append(this.contents); + if (this.contents.contains("\n")) { + // for some reason, some terminals reset sequences when a new line is added. + this.putContentsSanitized(buff); + } else { + buff.append(this.getStartSequences()); + buff.append(this.contents); + } + // then do the same thing for the concatenated formatters for (TextFormatter subFormatter : this.concatList) { - buffer.append(subFormatter); + buff.append(subFormatter); } - buffer.append(this.getEndSequences()); + buff.append(this.getEndSequences()); - return buffer.toString(); + return buff.toString(); + } + + /** + * Adds the start sequences to the contents of the formatter. This is done by adding the start sequences after + * every new line. (and at the first line) + * @param buff The buffer to add the contents to. + */ + private void putContentsSanitized(@NotNull StringBuilder buff) { + final var split = UtlString.splitAtLeadingWhitespace(this.contents); + final var startSequences = this.getStartSequences(); + + // start by adding the leading whitespace + buff.append(split.first()); + + // then add the start sequences + buff.append(startSequences); + + char[] charArray = split.second().toCharArray(); + for (int i = 0; i < charArray.length; i++) { + var chr = charArray[i]; + + // if we encounter a new line, and the next character is not a whitespace, then add the start sequences + if (chr == '\n' && (i < charArray.length - 1 && !Character.isWhitespace(charArray[i + 1]))) + buff.append(startSequences); + + // add the character + buff.append(chr); + } } /** Returns a template for a {@link TextFormatter} that is used for errors */ @@ -280,7 +321,7 @@ public boolean formattingNotDefined() { */ static @NotNull String getSequence(int code) { if (TextFormatter.debug) - return "ESC[" + code; + return "ESC[" + code + "]"; return "" + ESC + '[' + code + 'm'; } diff --git a/src/test/java/lanat/test/UnitTests.java b/src/test/java/lanat/test/UnitTests.java index 65f3c366..c07f2ba3 100644 --- a/src/test/java/lanat/test/UnitTests.java +++ b/src/test/java/lanat/test/UnitTests.java @@ -1,6 +1,7 @@ package lanat.test; import lanat.Argument; +import lanat.ArgumentGroup; import lanat.ArgumentType; import lanat.Command; import lanat.argumentTypes.CounterArgumentType; @@ -51,6 +52,9 @@ public class UnitTests { static { HelpFormatter.lineWrapMax = 1000; // just so we don't have to worry about line wrapping TextFormatter.enableSequences = false; // just so we don't have to worry about color codes + + // prefix char is set to auto by default (make sure tests run in windows too) + Argument.PrefixChar.defaultPrefix = Argument.PrefixChar.MINUS; } protected TestingParser setParser() { @@ -77,7 +81,12 @@ protected TestingParser setParser() { this.addCommand(new Command("subCommand2") {{ this.setErrorCode(0b1000); - this.addArgument(Argument.create(new IntegerArgumentType(), 'c').positional()); + + this.addGroup(new ArgumentGroup("exclusive-group") {{ + this.setExclusive(true); + this.addArgument(Argument.createOfBoolType("extra")); + this.addArgument(Argument.create(new IntegerArgumentType(), 'c').positional()); + }}); }}); }}; } diff --git a/src/test/java/lanat/test/manualTests/CommandTemplateExample.java b/src/test/java/lanat/test/exampleTests/CommandTemplateExample.java similarity index 98% rename from src/test/java/lanat/test/manualTests/CommandTemplateExample.java rename to src/test/java/lanat/test/exampleTests/CommandTemplateExample.java index c91fde58..0e9b275e 100644 --- a/src/test/java/lanat/test/manualTests/CommandTemplateExample.java +++ b/src/test/java/lanat/test/exampleTests/CommandTemplateExample.java @@ -1,4 +1,4 @@ -package lanat.test.manualTests; +package lanat.test.exampleTests; import lanat.Argument; import lanat.ArgumentGroup; diff --git a/src/test/java/lanat/test/exampleTests/ExampleTest.java b/src/test/java/lanat/test/exampleTests/ExampleTest.java new file mode 100644 index 00000000..e3512d31 --- /dev/null +++ b/src/test/java/lanat/test/exampleTests/ExampleTest.java @@ -0,0 +1,48 @@ +package lanat.test.exampleTests; + +import lanat.*; +import lanat.argumentTypes.CounterArgumentType; +import lanat.argumentTypes.IntegerArgumentType; +import lanat.argumentTypes.NumberRangeArgumentType; +import lanat.argumentTypes.StringArgumentType; +import lanat.utils.Range; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Test; + +public final class ExampleTest { + @Test + public void main() { + Argument.PrefixChar.defaultPrefix = Argument.PrefixChar.MINUS; +// TextFormatter.enableSequences = false; + new ArgumentParser("my-program") {{ + this.setCallbackInvocationOption(CallbacksInvocationOption.NO_ERROR_IN_ARGUMENT); + this.addHelpArgument(); + this.addArgument(Argument.create(new CounterArgumentType(), "counter", "c").onOk(System.out::println)); + this.addArgument(Argument.create(new Example1Type(), "user", "u").required().positional()); + this.addArgument(Argument.createOfBoolType("t").onOk(v -> System.out.println("present"))); + this.addArgument(Argument.create(new NumberRangeArgumentType<>(0.0, 15.23), "number").onOk(System.out::println)); + this.addArgument(Argument.create(new StringArgumentType(), "string", "s").onOk(System.out::println).withPrefix(Argument.PrefixChar.PLUS)); + this.addArgument(Argument.create(new IntegerArgumentType(), "test").onOk(System.out::println).allowsUnique()); + }}.parse(CLInput.from("-h --number 3' --c -c --c -cccelloc ++string hello -ccc")) + .printErrors() + .getParsedArguments(); + } + + public static class Example1Type extends ArgumentType { + @Override + public @Nullable String[] parseValues(@NotNull String... args) { + this.forEachArgValue(args, str -> { + if (str.equals("!")) { + this.addError("The user cannot be '!'.", ErrorLevel.ERROR); + } + }); + return args; + } + + @Override + public @NotNull Range getRequiredArgValueCount() { + return Range.from(2).toInfinity(); + } + } +} \ No newline at end of file diff --git a/src/test/java/lanat/test/manualTests/ManualTests.java b/src/test/java/lanat/test/manualTests/ManualTests.java deleted file mode 100644 index 54516626..00000000 --- a/src/test/java/lanat/test/manualTests/ManualTests.java +++ /dev/null @@ -1,38 +0,0 @@ -package lanat.test.manualTests; - -import lanat.ArgumentParser; -import lanat.CLInput; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayInputStream; -import java.util.Arrays; - -public final class ManualTests { - @Test - public void main() { - String input = " "; - - // write some stuff to stdin - System.setIn(new ByteArrayInputStream("hello world\ngoodbye".getBytes())); - - var parsed = ArgumentParser.parseFromInto( - CommandTemplateExample.class, - CLInput.from(input), - o -> o.exitIfErrors() - .printErrors() - .printHelpIfNoInput() - ); - - parsed.string - .ifPresentOrElse( - s -> System.out.println("String is present: " + s), - () -> System.out.println("String is not present") - ); - - System.out.println(parsed.number); - System.out.println(parsed.subCommand.counter); - System.out.println(parsed.subCommand.anotherSubCommand.counter); - System.out.println(parsed.stdin); - System.out.println(Arrays.toString(parsed.bytes)); - } -} \ No newline at end of file diff --git a/src/test/java/lanat/test/units/TestTerminalOutput.java b/src/test/java/lanat/test/units/TestTerminalOutput.java index 5e3dc8df..c4fefea7 100644 --- a/src/test/java/lanat/test/units/TestTerminalOutput.java +++ b/src/test/java/lanat/test/units/TestTerminalOutput.java @@ -9,6 +9,8 @@ public class TestTerminalOutput extends UnitTests { private void assertErrorOutput(String args, String expected) { final var errors = this.parser.parseGetErrors(args); + System.out.printf("Test error output:\n%s", errors.get(0)); + // remove all the decorations to not make the tests a pain to write assertEquals( expected, @@ -17,7 +19,6 @@ private void assertErrorOutput(String args, String expected) { .replaceAll(" *[│─└┌\r] ?", "") .strip() ); - System.out.printf("Test error output:\n%s", errors.get(0)); } @Test @@ -40,15 +41,14 @@ public void testLastRequiredArgument() { @Test @DisplayName("Tuple is highlighted correctly") - public void testExceedValueCount() { + public void testExceedValueCountTuple() { this.assertErrorOutput("--what [1 2 3 4 5 6 7 8 9 10]", """ ERROR - Testing --what [ 1 2 3 4 5 6 7 8 9 10 ] + Testing --what -> [ 1 2 3 4 5 6 7 8 9 10 ] <- Incorrect number of values for argument 'what'. Expected from 1 to 3 values, but got 10."""); } - @Test @DisplayName("Arrow points to the last token on last argument missing value") public void testMissingValue() { @@ -74,7 +74,7 @@ public void testMissingValueBeforeToken() { public void testMissingValueWithTuple() { this.assertErrorOutput("--what []", """ ERROR - Testing --what [ ] + Testing --what -> [ ] <- Incorrect number of values for argument 'what'. Expected from 1 to 3 values, but got 0."""); } @@ -84,7 +84,7 @@ public void testMissingValueWithTuple() { public void testInvalidArgumentTypeValue() { this.assertErrorOutput("foo subCommand another bar", """ ERROR - Testing foo subCommand another bar + Testing foo subCommand another bar <- Invalid Integer value: 'bar'."""); } @@ -93,8 +93,8 @@ public void testInvalidArgumentTypeValue() { public void testUnmatchedToken() { this.assertErrorOutput("[foo] --unknown", """ WARNING - Testing [ foo ] --unknown - Token '--unknown' does not correspond with a valid argument, value, or command."""); + Testing [ foo ] --unknown <- + Token '--unknown' does not correspond with a valid argument, argument list, value, or command."""); } @Test @@ -102,14 +102,23 @@ public void testUnmatchedToken() { public void testIncorrectUsageCount() { this.assertErrorOutput("foo --double-adder 5.0", """ ERROR - Testing foo --double-adder 5.0 <- + Testing foo -> --double-adder 5.0 <- Argument 'double-adder' was used an incorrect amount of times. Expected from 2 to 4 usages, but was used 1 time."""); this.assertErrorOutput("foo --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 --double-adder 5.0", """ ERROR - Testing foo --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 + Testing foo --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 --double-adder 5.0 -> --double-adder 5.0 <- Argument 'double-adder' was used an incorrect amount of times. Expected from 2 to 4 usages, but was used 5 times."""); } + + @Test + @DisplayName("Test group exclusivity error") + public void testGroupExclusivityError() { + this.assertErrorOutput("foo subCommand2 --extra --c 5", """ + ERROR + Testing foo subCommand2 --extra -> --c 5 <- + Multiple arguments in exclusive group 'exclusive-group' used."""); + } } \ No newline at end of file diff --git a/src/test/java/module-info.java b/src/test/java/module-info.java index 8f9b6c4f..5eb3e780 100644 --- a/src/test/java/module-info.java +++ b/src/test/java/module-info.java @@ -4,7 +4,7 @@ requires org.jetbrains.annotations; exports lanat.test to org.junit.platform.commons, lanat; - exports lanat.test.manualTests to org.junit.platform.commons, lanat; + exports lanat.test.exampleTests to org.junit.platform.commons, lanat; exports lanat.test.units to lanat, org.junit.platform.commons; exports lanat.test.units.commandTemplates to lanat, org.junit.platform.commons; } \ No newline at end of file