Lamp v4: Outline
Helle everyone o/ It has been a long and exciting journey, and I'm really happy to see the growth Lamp has been experiencing lately. As a natural consequence, we have learned what works well in Lamp, as well as what areas could use a bit of improvement.
Many features have been repeatedly requested by lots of users but were unfulfilled simply because the current design of Lamp could not bear it. Some parts in Lamp are a bit too simple to accommodate more sophisticated use cases, while others are far too complicated that they became too rigid.
To help the project move forward, I would like to introduce a few fundamental changes to the core areas of Lamp. Such changes should preserve the current set of features in Lamp, and also give space for newer additions.
In summary: Expect breaking changes in the upcoming period.
As a side note, I may be unable to streamline new features to the current version. I would like to give my full attention to refactoring Lamp and fixing its fundamental flaws that have been bugging me and others for a long while.
This refactor, while exciting, may take a good while.
This refactor should allow us to introduce the following:
- Arguments between subcommands:
/foo <argument> bar - Events that allow intercepting the creation process of commands and command parameters
- Easier identification of command aliases, giving significance to the order of strings provided in
@Commandand@Subcommand. - Allow arguments that consume more than 1 string, for example, a
Locationthat takesx y z - Introduction of more utility classes that provide small, modular, and reusable blocks of functionality
- Make it easier to specify functionality for generic types of arguments
- Better messaging and localizing system
- Overall design improvements to the system, including:
- Arbitrariness of priorities in value resolvers, suggestions, etc.
- Make CommandHandler misuse-resistant, and make sure you can't invoke methods in the wrong order.
- Fix
@Cooldown's funny quirks
So, while we are at it, I would like to hear your thoughts on what features you think Lamp is missing.
This will be a long process, and because this is a side project, I'll work on it when the chance allows.
This issue should track these changes, which will take place in the v4 branch. Contributions are welcome, but please pass them by me beforehand to make sure we're on the same page.
i'm very excited about this announcement
Hello o/
I will write a few comments outlining the most significant API changes.
Lamp v4 is not backwards-compatible
Change 1: ArgumentStack -----> StringStream + MutableStringStream
Brief
The ArgumentStack interface was great and modular. However, it was fundamentally flawed: It expected every parameter to consume 1 and only 1 string from the user input.
StringStream, on the other hand, is a utility class that parses input character by character. It makes no assumptions about what you do with the input. Parse a single word, consume the entire stream, or use your own string delimiter, it's all up to you.
This allows for fine-grained parsing of strings, allows parameters to consume as many tokens as they would like, and makes it easy to read and peek the input.
StringStream and MutableStringStream
A StringStream is an immutable source of text, which can be read by moving a cursor forward or backward. As its name implies, you cannot consume a StringStream.
A MutableStringStream extends StringStream but contains methods that allow you to move the cursor. This means you can read and consume input.
MutableStringStream stream = StringStream.createMutable("hello 123");
stream.peek(); // <--- 'h'
stream.peekSingleString(); // <--- 'hello'
stream.readSingleString(); // <---- reads 'hello' and moves forward
stream.peek(); // <---- ' ' (the space after 'hello')
stream.moveForward(); // <---- consume the space
stream.readInt(); // <----- returns 123
stream.moveBackward(3); // moves the cursor back by 3 characters
A StringStream can only peek its input, but cannot read (i.e. move the cursor forward) or consume it. This is intended as it guarantees that it's safe to pass around without being modified. A MutableStringStream, on the other hand, can be modified as needed.
Change 2: ValueResolver -----> ParameterType
Brief
ValueResolvers provide the ability to create custom parameters that parse the input and convert it to Java types that are more useful than raw strings. However, their limited design reduces their usefulness. For example, you cannot create ValueResolvers that consume more than one string. ValueResolvers are also not composable. If we have a type named Foo, we cannot use Foo[] or List<Foo> unless we manually register resolvers for them.
ParameterTypes aim to solve these problems. Combined with the StringStream API that provides fine control over string parsing, it allows us to:
- Create parameter types that can parse as much as they need
- Compose ParameterTypes to create more complex types, such as
Foo[],List<Foo>, etc.
Into ParameterType
This is a rough design of the ParameterType interface. It may change a bit before the final release, however, this is a good sketch of what it may look like
(The actual interface is slightly more complicated as it defines more generics, extends some interfaces, and provides additional overridable methods)
public interface ParameterType<T> {
T parse(
@NotNull MutableStringStream input,
@NotNull ExecutionContext context
);
default @Nullable List<String> defaultSuggestions() {
return Collections.emptyList();
}
default @NotNull PrioritySpec parsePriority() {
return PrioritySpec.defaultPriority();
}
The parse method
The parse method is the most fundamental piece in a ParameterType, as it contains the actual parsing logic of a ParameterType. A ParameterType may use it to read as many strings or characters as it needs, and returns the appropriate object. Here are some examples:
- A
StringParameterType:
@Override
public String parse(@NotNull MutableStringStream input, @NotNull ExecutionContext context) {
if (greedy)
return input.consumeRemaining();
return input.readString();
}
- An
IntParameterType:
@Override
public Integer parse(@NotNull MutableStringStream input, @NotNull ExecutionContext context) {
return input.readInt();
}
- An
EnumParameterType:
@Override
public E parse(@NotNull MutableStringStream input, @NotNull ExecutionContext<CommandActor> context) {
String key = input.readSingleString();
E value = byKeys.get(key.toLowerCase());
if (value != null)
return value;
throw new InvalidValueException("Invalid enum: " + key);
}
The parsePriority: Because Lamp v4 allows parameters to have virtually more than one type, it needs a strategy to differentiate which parameters may take precedence over others.
If we had two commands: /teleport <number> and /teleport <string>, running /teleport 10 should call the first one, even if the second one can work with 10 just fine.
For that, a ParameterType can (optionally) specify its priority over others, using the PrioritySpec API. Example priorities:
- priority(int) > priority(double)
- priority(boolean) > everything else
- priority(byte) > priority(int)
- priority(enum) > priority(int)
- priority(T) > priority(array of T) for any T
- priority(String) and priority(char) are the lowest of all
A PrioritySpec can explicitly be told to be higher or lower than other ParameterTypes, or to be of extremely low, or extremely high priority.
PrioritySpec for byte:
private static final PrioritySpec PRIORITY = PrioritySpec.builder()
.higherThan(DoubleParameterType.class)
.higherThan(FloatParameterType.class)
.higherThan(LongParameterType.class)
.higherThan(IntParameterType.class)
.higherThan(ShortParameterType.class)
.build();
PrioritySpec for char:
private static final PrioritySpec PRIORITY = PrioritySpec.lowest().toBuilder()
.higherThan(StringParameterType.class)
.build();
PrioritySpec for String:
PrioritySpec.lowest()
If two priorities are equal, Lamp will resort to other means to find the best execution candidate. This will be detailed in further posts.
Follow-up to ParameterTypes: The ParameterType.Factory
In older versions of Lamp, you would use a ValueResolverFactory to generate ValueResolvers dynamically. A ParameterType.Factory serves a similar purpose. It, however, has two vital differences:
- It's not bound to a
CommandParameter. This makes it more modular and easy to compose other ParameterTypes from - It has access to other ParameterTypes
Let's say we would like to define a ParameterType.Factory for T[], where T is any type that has a registered ParameterType.
An extremely over-simplified version would look like so:
public final class ArrayParameterTypeFactory implements ParameterType.Factory<CommandActor> {
@Override
public @Nullable <T> ParameterType<T> create(@NotNull Type parameterType, @NotNull AnnotationList annotations, @NotNull Lamp lamp) {
Type elementType = Classes.arrayComponentType(parameterType);
if (elementType == null)
return null;
@NotNull ParameterResolver<CommandActor, Object> componentType = lamp.resolver(elementType);
if (!(componentType instanceof ParameterType))
throw new IllegalArgumentException("Received a ContextParameter for the array type: " + elementType);
return new ArrayParameterType<>(((ParameterType) componentType), getRawType(elementType));
}
}