Compare commits

...

5 Commits

Author SHA1 Message Date
493msi 9804e9f3f5 Minor fixes 2020-09-24 03:42:39 +02:00
493msi c3db492a6d Moved generic resolvers to a separate package 2020-09-24 03:42:39 +02:00
493msi 5952a26fee Code restructure and began documentation 2020-09-24 03:42:34 +02:00
493msi aa70f1bbe2 [PlutoCommandParser] Code cleanup 2020-09-24 03:41:52 +02:00
493msi c3bd358bed Readded PlutoCommandParser 2020-09-24 03:40:53 +02:00
28 changed files with 1035 additions and 0 deletions

View File

@ -17,6 +17,7 @@ can now only be modified only through public setters
* `[PlutoCore]` Refactored `InputBus` and added several convenience methods
* `[PlutoCore]` Refactored input callbacks
* `[PlutoStatic]` Slight cleanup in the `Display` and `DisplayBuilder` classes
* `[PlutoCommandParser]` **Initial release**
## 20.2.0.0-alpha.1
* `[PlutoLib#cz.tefek.pluto.io.logger]` Refactored the Logger subsystem

View File

@ -0,0 +1,45 @@
# plutoengine:plutocommandparser
PlutoEngine's command parser.
## Description
PlutoCommandParser is an attempt to streamline my previous attempts at command parsers.
Its main goal is to provide a modular and flexible tokenizer, parser and evaluator
for a simple user-friendly CLI-like language called `PlutoCmd`.
## Goals
* High syntax error tolerance
* Provide implementations for basic Java types, such as primitives and Strings
* Allow extensibility while providing a strong foundation
* Complete user control over localization, no hardcoded Strings
## Non-goals
PlutoCmd is *not* a replacement for standard scripting languages and will most likely
never be a Turing-complete language.
## Implementation style
### Command implementation
Each command has its own *final* class, abstractions over CommandBase are howered allowed.
Note the usage of the `ConstantExpression` annotation over some interface methods. These
annotations **must** be respected - methods must be stateless and deterministic.
## PlutoCmd language specification
### General syntax
```
[prefix]command [arg1] [arg2] ... [argN]
```
`prefix` is an optional identifier String to distinguish PlutoCmd commands from other commands.
`command` is an alias for a command, handled by one of the command's handlers
`argX` is an argument of the invoked command-function, optionally quoted to preserve whitespace.

View File

@ -0,0 +1,9 @@
apply plugin: 'java-library'
description = ""
dependencies {
api project(":plutolib")
testImplementation("org.junit.jupiter:junit-jupiter:5.6.2")
}

View File

@ -0,0 +1,63 @@
package cz.tefek.pluto.command;
import java.lang.reflect.Modifier;
import cz.tefek.pluto.command.invoke.InvokeHandler;
public abstract class CommandBase implements ICommand
{
private final Class<? extends CommandBase> clazz;
public final Class<?> commandClass()
{
return this.clazz;
}
protected CommandBase()
{
this.clazz = this.getClass();
if (!Modifier.isFinal(this.clazz.getModifiers()))
{
// Class must be final
// Throwing an exception here is okay, since this is the developer's fault
//
// You can still create abstract wrappers for CommandBase, however implemented commands
// must be final.
throw new RuntimeException("Command classes MUST be final. Offender: " + this.clazz);
}
var methods = this.clazz.getMethods();
for (var method : methods)
{
// Silently skip methods without annotations
if (!method.isAnnotationPresent(InvokeHandler.class))
continue;
var modifiers = method.getModifiers();
if (Modifier.isStatic(modifiers))
{
// Method must be non-static
// Throwing an exception here is okay, since this is the developer's fault
throw new RuntimeException("Invoke handlers MUST NOT be static. Offender: " + method);
}
if (!Modifier.isPublic(modifiers))
{
// Method must be public
// Throwing an exception here is okay, since this is the developer's fault
throw new RuntimeException("Invoke handlers MUST be public. Offender: " + method);
}
}
}
@Override
public final int hashCode()
{
return this.name().hashCode();
}
}

View File

@ -0,0 +1,18 @@
package cz.tefek.pluto.command;
import cz.tefek.pluto.annotation.ConstantExpression;
public interface ICommand
{
@ConstantExpression
String name();
@ConstantExpression
String[] aliases();
@ConstantExpression
String description();
@ConstantExpression
Class<?> commandClass();
}

View File

@ -0,0 +1,96 @@
package cz.tefek.pluto.command.context;
import cz.tefek.pluto.command.CommandBase;
public class CommandContextBuilder
{
private final CommandContext ctx;
public CommandContextBuilder()
{
this.ctx = new CommandContext();
}
public CommandContextBuilder prefix(String prefix)
{
this.ctx.usedPrefix = prefix;
return this;
}
public CommandContextBuilder alias(String alias)
{
this.ctx.usedAlias = alias;
return this;
}
public CommandContextBuilder command(CommandBase command)
{
this.ctx.command = command;
return this;
}
public CommandContext resolved()
{
this.ctx.resolved = true;
return this.ctx;
}
public CommandContext unresolved(EnumCommandParseFailure cause)
{
this.ctx.resolved = false;
return this.ctx;
}
public static class CommandContext
{
private boolean resolved;
private EnumCommandParseFailure failureCause;
private String usedPrefix;
private String usedAlias;
private CommandBase command;
private CommandContext()
{
}
public String getUsedPrefix()
{
return this.usedPrefix;
}
public String getUsedAlias()
{
return this.usedAlias;
}
public CommandBase getCommand()
{
return this.command;
}
public boolean isResolved()
{
return this.resolved;
}
public EnumCommandParseFailure getFailureCause()
{
return this.failureCause;
}
}
public enum EnumCommandParseFailure
{
UNRESOLVED_PREFIX,
UNRESOLVED_COMMAND_NAME,
UNRESOLVED_PARAMETERS,
UNRESOLVED_UNEXPECTED_STATE
}
}

View File

@ -0,0 +1,28 @@
package cz.tefek.pluto.command.invoke;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Denotes a handler for an invocation of a command.
*
* <p><em>
* Method must be public and non-static.
* </em></p>
*
* <p>
* While classes implementing the Command API must be final, it is not required for the handler methods
* as that would be redundant.
* </p>
*
* @author 493msi
*
* @since 20.2.0.0-alpha.2
* */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface InvokeHandler
{
}

View File

@ -0,0 +1,255 @@
package cz.tefek.pluto.command.parser;
import java.util.PrimitiveIterator.OfInt;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import cz.tefek.pluto.command.CommandBase;
import cz.tefek.pluto.command.context.CommandContextBuilder;
import cz.tefek.pluto.command.context.CommandContextBuilder.CommandContext;
import cz.tefek.pluto.command.context.CommandContextBuilder.EnumCommandParseFailure;
import cz.tefek.pluto.command.registry.CommandRegistry;
public class CommandParser
{
private final String text;
private Set<OfInt> prefixes;
private EnumParserState state;
private StringBuilder prefixBuilder;
private StringBuilder commandNameBuilder;
private CommandBase command;
private StringBuilder parameterBuilder;
private CommandContextBuilder ctx;
private static final int CP_QUOTE = '"';
public CommandParser(String text)
{
this.text = text;
this.state = EnumParserState.BEGIN;
}
private boolean readCodepoint(int cp)
{
switch (this.state)
{
case READING_PREFIX:
this.prefixBuilder.appendCodePoint(cp);
this.prefixes.removeIf(ii -> ii.nextInt() != cp);
if (this.prefixes.isEmpty())
{
this.state = EnumParserState.END_NO_PREFIX;
return false;
}
if (this.hasEmptyPrefix())
{
this.ctx.prefix(this.prefixBuilder.toString());
this.state = EnumParserState.READING_COMMAND;
}
break;
case READING_COMMAND:
this.commandNameBuilder.appendCodePoint(cp);
if (Character.isWhitespace(cp))
{
if (!this.resolveCommand())
{
return false;
}
this.state = EnumParserState.READ_WHITESPACE;
}
break;
case READ_WHITESPACE:
if (Character.isWhitespace(cp))
{
break;
}
if (cp == CP_QUOTE)
{
this.state = EnumParserState.READING_PARAMETER_QUOTED;
}
else
{
this.parameterBuilder.appendCodePoint(cp);
this.state = EnumParserState.READING_PARAMETER;
}
break;
case READING_PARAMETER_QUOTED:
if (cp == CP_QUOTE)
{
this.state = EnumParserState.READING_PARAMETER_CANDIDATE_UNQUOTE;
}
else
{
this.parameterBuilder.appendCodePoint(cp);
}
break;
case READING_PARAMETER:
if (Character.isWhitespace(cp))
{
this.emitParameter();
this.state = EnumParserState.READ_WHITESPACE;
}
else
{
this.parameterBuilder.appendCodePoint(cp);
}
break;
case READING_PARAMETER_CANDIDATE_UNQUOTE:
if (Character.isWhitespace(cp))
{
this.emitParameter();
this.state = EnumParserState.READ_WHITESPACE;
}
else
{
this.parameterBuilder.appendCodePoint(cp);
this.state = EnumParserState.READING_PARAMETER_QUOTED;
}
break;
case END:
default:
this.state = EnumParserState.UNEXPECTED_STATE_FALLBACK;
return false;
}
return true;
}
private boolean resolveCommand()
{
var alias = this.commandNameBuilder.toString();
this.ctx.alias(alias);
this.command = CommandRegistry.getByAlias(alias);
if (this.command == null)
{
this.state = EnumParserState.END_NO_COMMAND;
return false;
}
this.ctx.command(this.command);
return true;
}
private void emitParameter()
{
}
private boolean hasEmptyPrefix()
{
return this.prefixes.stream().anyMatch(Predicate.not(OfInt::hasNext));
}
/**
* Parse using this parser and supplied prefixes. This function also
* resolves the command context and the parameters. Yeah it does a lot of
* stuff.
*
*/
public CommandContext parse(Set<String> prefixes)
{
if (this.state != EnumParserState.BEGIN)
{
throw new IllegalStateException("Cannot run a parser that is not in the BEGIN state.");
}
this.prefixBuilder = new StringBuilder();
this.ctx = new CommandContextBuilder();
this.prefixes = prefixes.stream().map(String::codePoints).map(IntStream::iterator).collect(Collectors.toSet());
if (prefixes.isEmpty() || this.hasEmptyPrefix())
{
this.state = EnumParserState.READING_COMMAND;
}
else
{
this.state = EnumParserState.READING_PREFIX;
}
var cps = this.text.codePoints();
for (var cpIt = cps.iterator(); cpIt.hasNext(); )
if (!this.readCodepoint(cpIt.next()))
break;
// Update the state for EOF
switch (this.state)
{
case READING_PARAMETER_QUOTED:
case READ_WHITESPACE:
case READING_PARAMETER:
case READING_PARAMETER_CANDIDATE_UNQUOTE:
this.state = EnumParserState.END;
this.emitParameter();
break;
case READING_COMMAND:
if (this.resolveCommand())
{
this.state = EnumParserState.END;
}
break;
default:
break;
}
// Check the end state
switch (this.state)
{
case READING_PREFIX:
case END_NO_PREFIX:
return this.ctx.unresolved(EnumCommandParseFailure.UNRESOLVED_PREFIX);
case END_NO_COMMAND:
return this.ctx.unresolved(EnumCommandParseFailure.UNRESOLVED_COMMAND_NAME);
case END:
break;
default:
return this.ctx.unresolved(EnumCommandParseFailure.UNRESOLVED_UNEXPECTED_STATE);
}
// At this point we are 100% sure the command was resolved and can validate the parameters
/*
*
* TODO: Validate parameters here
*
*/
return this.ctx.resolved();
}
}

View File

@ -0,0 +1,17 @@
package cz.tefek.pluto.command.parser;
public enum EnumParserState
{
BEGIN,
READING_PREFIX,
READING_COMMAND,
READING_PARAMETER,
READING_PARAMETER_QUOTED,
READING_PARAMETER_CANDIDATE_UNQUOTE,
READ_WHITESPACE,
END_NO_PREFIX,
END_NO_COMMAND,
END_EMISSION_FAILURE,
END,
UNEXPECTED_STATE_FALLBACK;
}

View File

@ -0,0 +1,26 @@
package cz.tefek.pluto.command.platform;
import cz.tefek.pluto.command.context.CommandContextBuilder.EnumCommandParseFailure;
public abstract class CommandPlatform
{
public abstract String getID();
public abstract String getName();
public boolean shouldWarnOn(EnumCommandParseFailure failure)
{
switch (failure)
{
case UNRESOLVED_PREFIX:
case UNRESOLVED_COMMAND_NAME:
case UNRESOLVED_UNEXPECTED_STATE:
return false;
default:
return true;
}
}
public abstract int getMessageLimit();
}

View File

@ -0,0 +1,71 @@
package cz.tefek.pluto.command.registry;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import cz.tefek.pluto.command.CommandBase;
import cz.tefek.pluto.io.logger.Logger;
import cz.tefek.pluto.io.logger.SmartSeverity;
public final class CommandRegistry
{
private static CommandRegistry instance;
private final Set<CommandBase> commands;
private final Map<String, CommandBase> aliasTable;
static
{
instance = new CommandRegistry();
}
private CommandRegistry()
{
this.aliasTable = new HashMap<>();
this.commands = new HashSet<>();
}
private void registerAlias(String alias, CommandBase command)
{
if (this.aliasTable.containsKey(alias))
{
Logger.logf(SmartSeverity.ERROR, "Alias '%s' for command '%s' is already used, skipping.%n", alias, command.name());
return;
}
this.aliasTable.put(alias, command);
}
public static void registerCommand(CommandBase command)
{
if (!instance.commands.add(command))
{
Logger.logf(SmartSeverity.ERROR, "Command '%s' is already registered, skipping.%n", command.name());
return;
}
instance.registerAlias(command.name(), command);
Arrays.stream(command.aliases()).forEach(alias -> instance.registerAlias(alias, command));
}
public static CommandBase getByAlias(String alias)
{
return instance.aliasTable.get(alias);
}
public static Set<CommandBase> getCommands()
{
return Collections.unmodifiableSet(instance.commands);
}
public static void clear()
{
instance = new CommandRegistry();
}
}

View File

@ -0,0 +1,24 @@
package cz.tefek.pluto.command.resolver;
public abstract class AbstractResolveException extends RuntimeException
{
/**
*
*/
private static final long serialVersionUID = -8934505669078637864L;
public AbstractResolveException(Exception exception)
{
super(exception);
}
public AbstractResolveException(String what)
{
super(what);
}
protected AbstractResolveException()
{
}
}

View File

@ -0,0 +1,6 @@
package cz.tefek.pluto.command.resolver;
public abstract class AbstractResolver
{
public abstract Class<?> getOutputType();
}

View File

@ -0,0 +1,13 @@
package cz.tefek.pluto.command.resolver;
public enum EnumResolveFailure
{
INT_PARSE,
LONG_PARSE,
FLOAT_PARSE,
DOUBLE_PARSE,
FRAC_INVALID_PERCENTAGE,
FRAC_PERCENTAGE_OUT_OF_RANGE,
FRAC_VALUE_HIGHER_THAN_BASE,
OTHER;
}

View File

@ -0,0 +1,22 @@
package cz.tefek.pluto.command.resolver;
public class ResolveException extends AbstractResolveException
{
/**
*
*/
private static final long serialVersionUID = -3098373878754649161L;
private EnumResolveFailure failure;
public ResolveException(EnumResolveFailure failure)
{
this.failure = failure;
}
public EnumResolveFailure getResolveFailure()
{
return this.failure;
}
}

View File

@ -0,0 +1,23 @@
package cz.tefek.pluto.command.resolver.generic;
import java.util.function.Function;
import cz.tefek.pluto.command.resolver.AbstractResolver;
public abstract class GenericResolver<R> extends AbstractResolver
{
protected Function<String, R> func;
public GenericResolver(Function<String, R> func)
{
this.func = func;
}
public R apply(String param)
{
return this.func.apply(param);
}
@Override
public abstract Class<? super R> getOutputType();
}

View File

@ -0,0 +1,15 @@
package cz.tefek.pluto.command.resolver.generic;
public class StringResolver extends GenericResolver<String>
{
public StringResolver()
{
super(String::valueOf);
}
@Override
public Class<String> getOutputType()
{
return String.class;
}
}

View File

@ -0,0 +1,23 @@
package cz.tefek.pluto.command.resolver.primitive;
import cz.tefek.pluto.command.resolver.EnumResolveFailure;
import cz.tefek.pluto.command.resolver.ResolveException;
public class BasicDoubleResolver extends DoubleResolver
{
public BasicDoubleResolver()
{
super(str ->
{
try
{
return Double.parseDouble(str);
}
catch (NumberFormatException e)
{
throw new ResolveException(EnumResolveFailure.DOUBLE_PARSE);
}
});
}
}

View File

@ -0,0 +1,22 @@
package cz.tefek.pluto.command.resolver.primitive;
import cz.tefek.pluto.command.resolver.EnumResolveFailure;
import cz.tefek.pluto.command.resolver.ResolveException;
public class BasicIntResolver extends IntResolver
{
public BasicIntResolver()
{
super(str ->
{
try
{
return Integer.parseInt(str);
}
catch (NumberFormatException e)
{
throw new ResolveException(EnumResolveFailure.INT_PARSE);
}
});
}
}

View File

@ -0,0 +1,23 @@
package cz.tefek.pluto.command.resolver.primitive;
import cz.tefek.pluto.command.resolver.EnumResolveFailure;
import cz.tefek.pluto.command.resolver.ResolveException;
public class BasicLongResolver extends LongResolver
{
public BasicLongResolver()
{
super(str ->
{
try
{
return Long.parseLong(str);
}
catch (NumberFormatException e)
{
throw new ResolveException(EnumResolveFailure.LONG_PARSE);
}
});
}
}

View File

@ -0,0 +1,26 @@
package cz.tefek.pluto.command.resolver.primitive;
import java.util.function.ToDoubleFunction;
import cz.tefek.pluto.command.resolver.AbstractResolver;
public class DoubleResolver extends AbstractResolver
{
protected ToDoubleFunction<String> func;
public DoubleResolver(ToDoubleFunction<String> func)
{
this.func = func;
}
public double apply(String param)
{
return this.func.applyAsDouble(param);
}
@Override
public Class<?> getOutputType()
{
return double.class;
}
}

View File

@ -0,0 +1,63 @@
package cz.tefek.pluto.command.resolver.primitive;
import cz.tefek.pluto.command.resolver.EnumResolveFailure;
import cz.tefek.pluto.command.resolver.ResolveException;
public class IntFractionResolver extends IntResolver
{
public IntFractionResolver(int base)
{
super(str -> parseAmount(str, base));
}
private static int parseAmount(String amountString, int base) throws ResolveException
{
if (amountString.equalsIgnoreCase("all") || amountString.equalsIgnoreCase("everything"))
{
return base;
}
if (amountString.equalsIgnoreCase("half"))
{
return base / 2;
}
if (amountString.endsWith("%"))
{
try
{
float percentage = Float.parseFloat(amountString.substring(0, amountString.length() - 1));
if (percentage < 0 || percentage > 100)
{
throw new ResolveException(EnumResolveFailure.FRAC_PERCENTAGE_OUT_OF_RANGE);
}
else
{
return Math.round(percentage / 100.0f * base);
}
}
catch (NumberFormatException e1)
{
throw new ResolveException(EnumResolveFailure.FRAC_INVALID_PERCENTAGE);
}
}
try
{
int amount = Integer.parseInt(amountString);
if (amount > base)
{
throw new ResolveException(EnumResolveFailure.FRAC_VALUE_HIGHER_THAN_BASE);
}
return amount;
}
catch (NumberFormatException e)
{
throw new ResolveException(EnumResolveFailure.INT_PARSE);
}
}
}

View File

@ -0,0 +1,26 @@
package cz.tefek.pluto.command.resolver.primitive;
import java.util.function.ToIntFunction;
import cz.tefek.pluto.command.resolver.AbstractResolver;
public class IntResolver extends AbstractResolver
{
protected ToIntFunction<String> func;
public IntResolver(ToIntFunction<String> func)
{
this.func = func;
}
public int apply(String param)
{
return this.func.applyAsInt(param);
}
@Override
public final Class<?> getOutputType()
{
return int.class;
}
}

View File

@ -0,0 +1,26 @@
package cz.tefek.pluto.command.resolver.primitive;
import java.util.function.ToLongFunction;
import cz.tefek.pluto.command.resolver.AbstractResolver;
public class LongResolver extends AbstractResolver
{
protected ToLongFunction<String> func;
public LongResolver(ToLongFunction<String> func)
{
this.func = func;
}
public long apply(String param)
{
return this.func.applyAsLong(param);
}
@Override
public final Class<?> getOutputType()
{
return long.class;
}
}

View File

@ -0,0 +1,41 @@
package cz.tefek.pluto.command.resolver;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import cz.tefek.pluto.command.resolver.primitive.BasicIntResolver;
class BasicIntResolverTest
{
@Test
void parse()
{
var resolver = new BasicIntResolver();
Assertions.assertEquals(5, resolver.apply("5"));
Assertions.assertEquals(-50, resolver.apply("-50"));
Assertions.assertEquals(155, resolver.apply("155"));
Assertions.assertEquals(0, resolver.apply("0"));
Assertions.assertEquals(-0, resolver.apply("-0"));
}
@Test
void exceptions()
{
var resolver = new BasicIntResolver();
// No foreign characters
Assertions.assertThrows(NumberFormatException.class, () -> resolver.apply("abc"));
// No floats
Assertions.assertThrows(NumberFormatException.class, () -> resolver.apply("12.5"));
// No empty strings
Assertions.assertThrows(NumberFormatException.class, () -> resolver.apply(""));
}
}

View File

@ -0,0 +1,31 @@
package cz.tefek.pluto.command.resolver.command;
import cz.tefek.pluto.command.CommandBase;
import cz.tefek.pluto.command.invoke.InvokeHandler;
public final class TestCommand extends CommandBase
{
@Override
public String name()
{
return "test";
}
@Override
public String[] aliases()
{
return new String[0];
}
@Override
public String description()
{
return "The test command - prints Hello World to stdout.";
}
@InvokeHandler
public void invoke()
{
System.out.println("Hello World!");
}
}

View File

@ -0,0 +1,21 @@
package cz.tefek.pluto.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Denotes that the target field or method should be a constant expression - it has no state and always yields
* the same deterministic result for given input. Generally, annotated methods should be thread-safe, however
* this is not required.
*
* @author 493msi
*
* @since 20.2.0.0-alpha.2
* */
@Retention(RetentionPolicy.CLASS)
@Target({ ElementType.METHOD, ElementType.FIELD })
public @interface ConstantExpression
{
}

View File

@ -1,4 +1,5 @@
include 'plutolib',
'plutocommandparser',
'plutostatic',
'plutotexturing',
'plutomesher',