-
Notifications
You must be signed in to change notification settings - Fork 0
Bind Framework Guide
The bind framework is the other big part of mela-command and probably the reason why you are reading about this project in the first place. This guide is an introduction to the framework and its standard library.
Just like in the core framework guide, we're going to write a small CLI application, this time a more complex one: a little file explorer and manager.
It is assumed you know the basics of mela-command, i.e. the core framework. Everything that is already explained in the core framework guide will not be repeated here.
To try along with this code, you need to include mela-command-provided
(the bind framework standard library) in your project. For Gradle/Maven setup, see here.
Unlike in the core framework, command classes using the bind framework need not implement CommandCallable
. In fact, command classes can be pretty much anything.
This is because the bind framework uses annotations instead of type hierarchies. Each command method - which there can be any amount of per class - needs to be annotated with the @Command
annotation.
Just like in the other guide, let's start with a version command. This time though, we're going to group it together with the exit command in one class.
public class ApplicationCommands {
private static final String VERSION = "1.0.0";
@Command(
labels = {"version", "v"},
desc = "Displays the version of the application",
help = "Simply type \"version\" or \"v\".",
usage = "[version|v]"
)
public void displayVersion(@Remaining String ignored) {
System.out.printf("Current version is: %s%n", VERSION);
}
@Command(
labels = {"exit", "bye"},
desc = "Exits the application",
help = "Type \"exit\" and the programme will stop.",
usage = "[exit|bye]"
)
public void exit(@Remaining String ignored) {
System.out.println("Goodbye");
System.exit(0);
}
}
The @Command
annotation has the exact same parameters as the contructor of CommandCallableAdapter
, just that "description" is shortened to "desc". Every parameter also has a default value. For the labels, it's an empty array (again, that means that the command is the default command of a group and can be called by its group name), for the information parameters, it's the empty String, which is semantically equivalent to null
in the core framework. The reason why they're empty rather than null
is that annotations don't permit null
values.
You can see an odd thing, if you look at the method declarations. They both have a parameter that's never used.
This is because argument parsing works differently for the bind framework. In the core framework, the arguments are passed directly to the command and you can decide what to do with them. This is not the case in the bind framework, where the framework handles the arguments. In the other guide, we just didn't use the arguments. In this one however, if we were to declare the methods with no parameters, the result would be different, because the bind framework throws an exception if any arguments are left unused. Otherwise, user input could possibly be ignored silently.
This means that to make a command that "doesn't care" about any additional arguments and just ignores them, we need to declare a parameter that consumes all arguments that are left. The bind framework standard library provides the @Remaining
annotation, which does exactly that by passing all remaining arguments combined to the mapper for the parameter. The mapper in our case simply consumes the next String. The result of this is that no arguments are left and no exception is thrown, no matter how many arguments are provided.
The file explorer should support the following commands:
-
version
- displays the version of the application -
cd <directory>
- changes the working directory (by resolving the given one against it) -
list <directory>
- lists all files and directories in the given directory or if no directory is given, in the current directory. -
file
commands:-
create <file>
- creates an empty file with the given name + its parent directories if they don't exist -
show <file>
- displays the content of a file in plain text -
edit <file> <text>
- edits the given file, replacing all of its content with the given text -
delete <file>
- deletes the given file or directory -
move <from> <to>
- moves the "from" file or directory to the "to" file or directory
-
-
help <command>
- displays general help or information about the given command
We are going to use a similar loop as in the core framework guide. Still, a few things are different this time.
Scanner scanner = new Scanner(System.in);
Path workingDirectory = Paths.get(".").toAbsolutePath().normalize();
CommandContext context = CommandContext.create();
context.put(Path.class, "working", workingDirectory);
while (true) {
System.out.printf("%s > ", context.get(Path.class, "working")
.orElseThrow(AssertionError::new));
String command = scanner.nextLine();
try {
dispatcher.dispatch(command, context);
} catch (UnknownCommandException e) {
System.out.println("Unknown command. Use \"help\" for help.");
}
}
For the first time, there is a real usage for CommandContext
. In this case, we reuse a single instance to keep track of the current working directory. At the beginning, it's just going to be the directory in which the application was started (.
). We always want to use it as an absolute path and without any "special" names like .
for current directory or ..
for parent directory, so we call toAbsolutePath()
and normalize()
in succession.
The working directory is stored in the CommandContext
using the type:key combination of Path.class
and "working"
. This will later be important to resolve user inputted paths.
Before we continue, we should probably clear up some questions about the basic idea behind the bind framework.
The bind framework is, as the name indicates, based on so-called bindings. To show what this means, let me give you a command example:
@Command(...)
@Requires(permission = "user.ban")
public void ban(
@Context("server") Server server,
@Flag("-time") @Default("1y") Duration time,
User target,
@Maybe String reason
) {
server.ban(target, time);
target.sendMessage("You have been banned.");
if (reason != null) {
target.sendMessage("Reason: " + reason);
}
}
This is just a hypothetical command, the APIs used are made up.
Now, when the bind framework encounters this method, it recognises that it's a command method (because of the annotation) and now has to figure a few things out:
-
Possible command interceptor bindings.
CommandInterceptor
s use annotations that target the command method. For each method annotation, mela checks whether there is a binding to aCommandInterceptor
instance. If so, that interceptor will be run before each time the command is executed.@Requires
in this example could be bound to an interceptor that checks whether the executor has the permission "user.ban" and otherwise would throw an exception. -
The parameters. Those are the most important part, because they define how the arguments are parsed. Each parameter can (optionally) be annotated with
@Description
for a parameter description and@Name
for a parameter name. If no description is provided, it will default tonull
, if no name is provided, it will default to the name of the parameter as declared in the class file. Both of those are built-in and can be used for further information in help commands, for example. Apart from that, the bind framework also checks for two additional things:-
Parameter type bindings. Each parameter type needs to have an appropriate
ArgumentMapper
binding, otherwise mela does not know how to parse and map the argument and therefore throws an error. In this case, there would need to be a binding forDuration
,User
andString
(technically forServer
too, but since it's a value present inCommandContext
, i.e., that needs no mapping, the standard library circumvents this requirement). No type has a built-in binding, not evenString
. The most common bindings that are often reused across projects are implemented in the bind framework standard library (aka the provided module). -
Possible mapping interceptor bindings. Each parameter and each parameter type (yes, there is a difference, it will be elaborated on later) may have any amount of
MappingInterceptor
s applied. LikeCommandInterceptor
s, they are bound to annotations and can be used for various purposes. They have one method to verify their application (e.g. to check whether the annotated type is supported by the interceptor) and two methods, one that is run before and another that is run after an argument was mapped. Those methods can be used to change input arguments (like@Default
or@Flag
), to ignore errors, missing arguments and/or directly set a value (like@Maybe
or@Context
) or to validate mapped arguments (like@Range
or@Match
, neither of which are used here though).
-
All of these bindings are bundled in one class: CommandBindings
. You can create an instance of CommandBindings
by using the builder or merging existing bindings. The standard library bindings can be acquired via ProvidedBindings.get()
.
For the moment, we'll only use the standard library bindings and add more as we progress in writing the commands.
We are still going to keep using the structures of the core framework. For that reason, we need to use a CommandCompiler
that transforms or bind framework command objects to CommandCallable
s.
The only compiler that is currently available for the bind framework is MethodHandleCompiler
, which produces CommandCallable
s that call our command methods using java MethodHandles.
Again, we're going to start by only using the standard library bindings:
CommandBindings standardLibrary = ProvidedBindings.get();
CommandCompiler compiler = MethodHandleCompiler.withBindings(standardLibrary);
After this, group building almost remains the same:
CommandGroup root = ImmutableGroup.builder()
.add(new ApplicationCommands())
.add(new HelpCommand())
.add(new ListCommand())
.add(new CdCommand())
.group("file", "f")
.add(new FileCommands())
.root()
.compile(compiler);
The only different thing is that we don't use build()
anymore, since it only works for direct core framework interaction. Instead, we compile the group now using the compiler we just created.
There is one central interface that our file-explorer will use over and over again as a parameter type: Path
. It is used as a parameter for cd
, list
, file create
, file edit
, file open
, file delete
and file move
. In each of those cases, we need one (in one case even two) parameter that needs to be parsed to a Path
. Not only that, but we also need to consider the current working directory, i.e. the CommandContext
every time.
Here's where aspect-oriented programming comes in. Instead of parsing the arguments manually for every command, we'll extract the algorithm into an ArgumentMapper
and let mela-command do the rest.
public class PathMapper implements ArgumentMapper<Path> {
@Override
public Path map(@Nonnull CommandArguments arguments, @Nonnull CommandContext commandContext) {
Path workingDirectory = commandContext.get(Path.class, "working")
.orElseThrow(AssertionError::new);
if (arguments.hasNext()) {
try {
return workingDirectory
.resolve(Paths.get(arguments.nextString()))
.toAbsolutePath()
.normalize();
} catch (InvalidPathException e) {
throw new MappingProcessException("Provided argument is not a valid path", e);
}
} else {
return workingDirectory;
}
}
}
To create such a mapper, we need to implement ArgumentMapper
with the mapped type as the actual type argument.
The algorithm to map a Path
parameter is quite simple:
-
Get the current working directory from the
CommandContext
-
If there's an argument, use it as a path and resolve it against the working directory
-
If there's no argument, return the working directory. We do that because many commands use the working directory as a default if no path is provided and this behaviour doesn't break the other commands.
The reason why we basically rethrow the InvalidPathException
as a MappingProcessException
is so we only need to handle MappingProcessException
later on (there will be more instances where this exception may occur).
We can add it to our bindings like so:
CommandBindings fileExplorerBindings = CommandBindings.builder()
.bindMapper(Path.class, new PathMapper())
.build();
Now, this mapper makes our commands already a lot shorter. Let's take the file show
command, for example:
// core framework command
public class FileOpenCommand extends CommandCallableAdapter {
public FileOpenCommand() {
super(
ImmutableList.of("show", "read"),
"Displays the content of a file in plain text",
"The provided path must be a file, not a directory.",
"file [show|read] <file>"
);
}
@Override
public void call(CommandArguments arguments, CommandContext context) {
if (arguments.hasNext()) {
Path workingDirectory = context.get(Path.class, "working")
.orElseThrow(AssertionError::new);
Path file = workingDirectory
.resolve(Paths.get(arguments.nextString()))
.toAbsolutePath()
.normalize();
if (File.notExists(file)) {
System.out.println("Invalid argument: file must exist");
return;
}
if (!Files.isRegularFile(file)) {
System.out.println("Invalid argument: provided path must be a regular file");
return;
}
Files.readAllLines(file).forEach(System.out::println);
} else {
System.out.println("Please provide a file to open");
}
}
}
// bind framework command
@Command(
labels = {"show", "read"},
desc = "Displays the content of a file in plain text",
help = "The provided path must be a file, not a directory.",
usage = "file [show|read] <file>"
)
public void show(Path file) throws IOException {
if (Files.notExists(file)) {
System.out.println("Invalid argument: file must exist");
return;
}
if (!Files.isRegularFile(file)) {
System.out.println("Invalid argument: provided path must be a regular file");
return;
}
Files.readAllLines(file).forEach(System.out::println);
}
Now consider that this mapping needs to happen for almost every command. It is therefore a significant difference.
But this is not really satisfying yet. After all, there is still more boilerplate than there is command logic, because we need to check whether the path exists and then whether it points to a file. And this is not the only place where we need to do stuff like this: there are four conditions that we need to check in various commands: Whether a path exists, whether a path does not exist, whether a path is a file and whether a path is a directory.
For example, the file move
command doesn't care whether the path is a file or a directory, but the source path must exist and the target path must not exist. cd
as a different example needs a directory path that exists.
Those checks can also be extracted and removed from the command methods, using MappingInterceptor
s.
These checks are all very similar, so we can use one class for all of them:
public class PathValidator<T extends Annotation> extends MappingInterceptorAdapter<T> {
private final Predicate<Path> predicate;
private final String errorMessage;
public PathValidator(Predicate<Path> predicate, String errorMessage) {
this.predicate = predicate;
this.errorMessage = errorMessage;
}
@Override
public void postprocess(
@Nonnull T annotation,
@Nonnull MappingProcess process,
@Nonnull CommandContext context
) {
if (process.isSet() && process.getValue() != null) {
if (!predicate.test((Path) process.getValue())) {
throw new ArgumentValidationException("Invalid path; " + errorMessage);
}
}
}
@Override
public void verify(@Nonnull T annotation, @Nonnull TargetType type) {
if (type.getType() != Path.class) {
throw new IllegalTargetTypeError(type.getType(), annotation.annotationType());
}
}
}
The only thing we need to verify is that the annotated type is Path
. And since all of the validation happens after mapping, we only need to implement postprocess
. This is possible because we extend MappingInterceptorAdapter
and therefore don't implement MappingInterceptor
directly.
First, we check whether a value has even been set in the process. If not, we can skip the check anyway. Then we test against the predicate and if the test fails, we throw an ArgumentValidationException
, which is a subclass of MappingProcessException
.
Now, if you still remember, MappingInterceptor
s, like CommandInterceptor
s, are bound to annotations. This means that we need to write an annotation for each of our conditions.
If you've never written annotations before, don't worry, it's extremely simple. Let's make an annotation for the interceptor that checks whether the path exists:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_USE)
public @interface Existent {
}
And that's it! The runtime retention policy is required, otherwise the annotation can't be accessed by reflection. Furthermore, the element type to target should be TYPE_USE
. What to choose here can be a bit harder, so let me break the decision down for you.
There are two possible options for mapping interceptor annotations: TYPE_USE
and PARAMETER
.
The only difference in this context is that TYPE_USE
annotations can also be applied to type arguments or component types of arrays. Consider a command that should delete all given paths:
@Command(labels = "deleteall")
public void deleteAll(List<@Existent Path> paths) {
// ...
}
Each path in the list must exist in order to delete it, therefore it makes sense that we can apply the @Existent
annotation there. If we applied it to List<...>
instead, we would get an IllegalTargetTypeError
, as specified in the corresponding interceptor. Since it can be checked for every Path
whether it exists or not - no matter where it's declared - it makes sense that @Existent
targets TYPE_USE
.
As opposed to that, take an example annotation where TYPE_USE
doesn't make sense:
@Command(labels = "example")
public void example(@Flag("f") boolean flag) {
// ...
}
Flags may only be applied to parameters. It wouldn't make sense for a @Flag
annotation to be applied like List<@Flag("f") Boolean>
, because flags have nothing to do with the type but indicate a special kind of parameters. The same goes for @Context
.
Now that that's clear, we just need to create 3 more annotations for the rest of our conditions. I'm going to save some space here: they're the exact same as @Existent
, just with different names.
Then we can add them to our bindings:
CommandBindings fileExplorerBindings = CommandBindings.builder()
.bindMapper(Path.class, new PathMapper())
.bindMappingInterceptor(Existent.class,
new PathValidator<>(Files::exists, "Path must exist"))
.bindMappingInterceptor(NonExistent.class,
new PathValidator<>(Files::notExists, "Path must not exist"))
.bindMappingInterceptor(File.class,
new PathValidator<>(Files::isRegularFile, "Path must point to a file"))
.bindMappingInterceptor(Directory.class,
new PathValidator<>(Files::isDirectory, "Path must point to a directory"))
.build();
Now, the file show
command looks like this:
@Command(
labels = {"show", "read"},
desc = "Displays the content of a file in plain text",
help = "The provided path must be a file, not a directory.",
usage = "file [show|read] <file>"
)
public void show(@Existent @File Path file) throws IOException {
Files.readAllLines(file).forEach(System.out::println);
}
We've achieved our goal: the only thing left in the command method is the logic, i.e. reading the file and printing its contents to the console. The same goes for all of the other commands which all use the bindings we previously established.
But there is one thing left: Exception handling. Right now, if anything goes wrong the application will terminate with an exception. This is obviously not what we want.
mela-command embraces command- and argument-related exceptions. Those exceptions are therefore not supposed to be prevented, but to be handled externally. This means that you should in fact throw them to indicate that something went wrong rather than checking for conditions and responding in your command methods.
To respond to exceptions, the bind framework provides the ExceptionHandler
interface. In our case, we only want to tell the user about the exception, nothing more. Thus, we can use a generic ExceptionHandler
implementation for all kinds of exceptions.
public class PrintExceptionHandler<T extends Throwable> implements ExceptionHandler<T> {
private final String message;
public PrintExceptionHandler(String message) {
this.message = message;
}
@Override
public void handle(@Nonnull T exception, @Nonnull CommandContext context) {
System.out.printf("%s: %s%nUse \"help <command>\" for help.%n", message, exception);
}
}
The message before the colon is the only thing that differs between exception types, as you'll see shortly. Let's add some handlers to our bindings:
CommandBindings fileExplorerBindings = CommandBindings.builder()
.bindMapper(Path.class, new PathMapper())
.bindMappingInterceptor(Existent.class,
new PathValidator<>(Files::exists, "Path must exist"))
.bindMappingInterceptor(NonExistent.class,
new PathValidator<>(Files::notExists, "Path must not exist"))
.bindMappingInterceptor(File.class,
new PathValidator<>(Files::isRegularFile, "Path must point to a file"))
.bindMappingInterceptor(Directory.class,
new PathValidator<>(Files::isDirectory, "Path must point to a directory"))
.bindHandler(AccessDeniedException.class,
new PrintExceptionHandler<>("Access denied"))
.bindHandler(MappingProcessException.class,
new PrintExceptionHandler<>("Invalid argument"))
.bindHandler(ArgumentException.class,
new PrintExceptionHandler<>("Wrong number of arguments provided"))
.bindHandler(IOException.class,
new PrintExceptionHandler<>("An I/O problem occurred"))
.build();
AccessDeniedException
s occur when we access files or directories with missing permissions, MappingProcessException
s (as seen before) happen if an argument can't be mapped or validated, ArgumentException
s happen if too few/too many arguments are provided and IOException
s happen, if - well, what do we know. They sometimes just happen. This should cover all possible exceptions of our application. In case of any severe error or anything else we don't want to proceed anyway.
ExceptionHandler
s have the useful property of accepting all subtype exceptions as well. For example, as seen earlier, ArgumentValidationException
s may occur in our application. Since there is no binding for this exception class, mela-command will go through its parent classes to see if they have a binding. In this case, MappingProcessException
does, so those exceptions will be handled there.
If no handler binding is found, the exception is rethrown, wrapped in a RuntimeException
.
Below, you can see an implementation of all the file
commands. The code of the entire application can be found here.
public class FileCommands {
@Command(
labels = "create",
desc = "Creates an empty file with the given name "
+ "and its parent directories if they don't exist",
help = "Run \"file create\" with an absolute path or with a relative path to create "
+ "the file in the working directory. "
+ "Use the optional -e flag to write some initial content.",
usage = "file create [-e <content>] <file>"
)
public void create(@Maybe @Flag("e") String content, @NonExistent Path path) throws IOException {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.createFile(path);
System.out.printf("Created file %s%n", path);
if (content != null) {
edit(path, content);
}
}
@Command(
labels = {"show", "read"},
desc = "Displays the content of a file in plain text",
help = "The provided path must be a file, not a directory.",
usage = "file [show|read] <file>"
)
public void show(@Existent @File Path file) throws IOException {
Files.readAllLines(file).forEach(System.out::println);
}
@Command(
labels = {"edit", "write"},
desc = "Edits the given file, replacing all of its content with the given text",
help = "The provided path must be a file, not a directory.",
usage = "file [edit|write] <file> <text>"
)
public void edit(@Existent @File Path file, @Remaining String text) throws IOException {
Files.write(file, text.getBytes(StandardCharsets.UTF_8));
System.out.printf("Edited file %s%n", file);
}
@Command(
labels = {"delete", "del"},
desc = "Deletes the given file or directory",
help = "If you provide a directory, all sub-directories and "
+ "files in it will be deleted as well.",
usage = "file [delete|del] <path>"
)
public void delete(@Existent Path path) throws IOException {
if (Files.isDirectory(path)) {
Files.walkFileTree(path, new FileDeleteVisitor());
} else {
Files.delete(path);
}
System.out.printf("Deleted %s%n", path);
}
private static class FileDeleteVisitor extends SimpleFileVisitor<Path> {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
}
@Command(
labels = {"move", "mov"},
desc = "Moves a file or directory to a given target path",
help = "This command can also be used to rename a file or directory. "
+ "Note that the target path must not exist already.",
usage = "file [move|mov] <source> <target>"
)
public void move(@Existent Path source, @NonExistent Path target) throws IOException {
Files.move(source, target);
System.out.printf("Moved %s to %s%n", source, target);
}
}
- Command Implementation
- Command Groups
- Dispatching Commands
- Command Contexts
- Argument Parsing
- Compile API
- Overview
- Mapper Bindings
- Mapping Interceptor Bindings