Skip to content

Core Framework Guide

Johnny edited this page Mar 25, 2020 · 9 revisions

The core framework is the basis for everything in mela-command. This guide is an introduction to the basic structures and semantics of the project.

For this purpose, let's write a simple CLI application that has a few fun commands and a few utilities.

  1. Prerequisites

  2. Commands

  3. Example CLI Commands

  4. CLI Command Loop

  5. Groups

  6. Dispatching

  7. Contexts

  8. Example Commands Implementation

    1. Leetify

    2. Help

Prerequisites

To try along with this code, you need to include mela-command-core in your project. For Gradle/Maven setup, see here.

Commands

Commands in mela-command are defined by the interface CommandCallable. This interface has several methods: a few that return information about the command and a call(CommandArguments, CommandContext) method that defines what the command actually does when called. The information methods may all return null; they are solely there for user convenience (if you want to write a help command, for instance). The only method ever called by the core framework is call(CommandArgument, CommandContext).

In most cases, rather than implementing CommandCallable directly, it is more convenient to extend the abstract class CommandCallableAdapter which implements all of the information methods and only requires you to implement the call method.

For our CLI, here is a command that displays the version of our application:

public class VersionCommand extends CommandCallableAdapter {

  private static final String VERSION = "1.0.0";

  public VersionCommand() {
    super(ImmutableList.of("version", "v"), 
        "Displays the version of the application", null, "\"version\" or \"v\"");
  }    

  @Override
  public void call(CommandArguments arguments, CommandContext context) {
    System.out.printf("Current version is: %s%n", VERSION);
  } 
}

Let's walk through the super constructor call there.

  • ImmutableList.of("version", "v") denotes the labels of the command, i.e. what someone has to type in order to execute it. The parameter takes any Iterable<String>, but note that duplicate labels are erased. It is even possible to provide an empty Iterable, which will result in the command being available as a default command for a group. That typically means that it will be called if the input cannot be resolved to any other command. ImmutableList comes from Guava, which is a transitive dependency of mela-command.

  • "Displays the version of the application" is the description String. It should contain a short one-ish-sentence description of the command, but may also be null if not required.

  • The command help message in this case is null. Usually, it would contain a more detailed description and explanation of how to use the command. Because the version command is so simple, there is no need for it.

  • "\"version\" or \"v\"" is the usage attribute. It should give a quick and concise overview of how to use the command and its syntax. Like the two before, it is also nullable.

This is as simple as it gets. No arguments, no context required.

Example CLI Commands

The application should support the following commands:

  • version - displays the version of the application

  • help <command> - displays general help or information about a specific command

  • exit - exits the application

  • fun

    • randomise <text> - randomises the characters in the middle of a word, e.g. "hello world" -> "hlelo wlord"

    • leetify <text> - transforms the given text to leet speak, e.g. "hello world" -> "h3110 w0r1d"

    • mock <text> - "mocks" the text like the spongebob meme, e.g. "hello world" -> "hELlO WoRLd"

  • util

    • encode <text> - "encodes" the given ASCII text in binary

    • decode <binary> - "decodes" the given binary numbers to a string again

We've already written the version command. Let's continue with the setup before writing any of the other commands.

CLI Command Loop

To implement and use the above commands, we need some sort of general structure that allows us to execute commands in the first place.

For this purpose, we are going to use an infinite loop and a java.util.Scanner:

Scanner scanner = new Scanner(System.in);
while (true) {
  String command = scanner.nextLine();
  // dispatch command
}

Simple enough. To dispatch commands, we need a CommandDispatcher. We are going to use the default implementation DefaultDispatcher.

Now, creating a DefaultDispatcher requires an argument: a CommandGroup.

Groups

Next to CommandCallable and CommandDispatcher, CommandGroup is one of the essential interfaces of mela-command. A group is a tree-like structure where each group instance is a node of the tree. Along with any amount of children, each group has a set of CommandCallables and names/identifiers. The main purpose of groups is to enable "sub commands" - i.e., allowing us to write commands that share a common command prefix (like in our example fun or util) as if they didn't have that prefix.

As an example, this is what the above command structure would look like:

                                      ┌────────────────┐
                                      │ <root> (group) │
                                      └───────┬────────┘
                  ┌───────────────────────────┴─┬───────────────┬──────────┐
           ┌──────┴──────┐               ┌──────┴───────┐  ┌────┴────┐  ┌──┴───┐
           │ fun (group) │               │ util (group) │  │ version │  │ help │
           └──────┬──────┘               └──────┬───────┘  └─────────┘  └──────┘
     ┌────────────┴┬───────────┐          ┌─────┴─────┐
┌────┴────┐  ┌─────┴─────┐  ┌──┴───┐  ┌───┴────┐  ┌───┴────┐
│ leetify │  │ randomise │  │ mock │  │ encode │  │ decode │
└─────────┘  └───────────┘  └──────┘  └────────┘  └────────┘

The result of this: although the leetify-command is only labelled leetify, it is executed using fun leetify, the encode command is executed using util encode and so on.

Unless you use Guice, CommandGroups are usually created by using an ImmutableGroupBuilder, which lets us emulate the structure above:

HelpCommand helpCommand = new HelpCommand();
CommandGroup root = ImmutableGroup.builder()
    .add(new VersionCommand()) // these commands are in the root group
    .add(helpCommand)
    .add(new ExitCommand())
    .group("fun", "f") // any amount of names possible
      .add(new LeetifyCommand())
      .add(new RandomiseCommand())
      .add(new MockCommand())
    .parent() // goes up one step (in this case, to the root)
    .group("util", "u")
      .add(new EncodeCommand())
      .add(new DecodeCommand())
    .root() // goes back to the root group from any depth
    .build();
helpCommand.setGroup(root); // why are we doing this with the help command? You'll see later.

When you type this code into an IDE, you might notice that the add(...) method accepts any Object, not just CommandCallables.

This is because ImmutableGroupBuilder allows the usage of a CommandCompiler. CommandCompilers transform generic Objects to CommandCallables. This is utilised by the bind framework: there, the commands we add need not implement CommandCallable and are instead compiled to CommandCallables. As we only use the core framework in this guide and we implement CommandCallable directly, no transformation is required. The build() method internally calls compile(new IdentityCompiler()) though, which does no more than checking whether the objects we added as commands are instances of CommandCallable.

Important: Before calling build() or compile(...), you should go back to the root group (as shown in the snippet), because those methods assume the current group of the builder to be the root group. If you do not do that, you won't get the root group as a result.

Dispatching

Now we can create a CommandDispatcher:

CommandDispatcher dispatcher = DefaultDispatcher.create(root);
Scanner scanner = new Scanner(System.in);
while (true) {
  System.out.print("> ");
  String command = scanner.nextLine();
  try {
    dispatcher.dispatch(command, CommandContext.create());
  } catch (UnknownCommandException e) {
    System.err.println("Unknown command. Use \"help\" for help.");
  }
}

As you can see, the dispatch method throws an exception if the input cannot be resolved to a command.

You may also notice that the method takes an additional argument: an instance of CommandContext. Let's talk about this parameter a bit more.

Contexts

The CommandContext class is used to store arbitrary data that relates to command execution. Without it, mela-command wouldn't be generally applicable.

The usefulness of command contexts is easier illustrated when considering usages of mela in combination with other frameworks and environments than CLI. Consider a messenger bot that dispatches commands using mela-command. There, the command context could be used to store the user who executed the command, the server and channel it was executed in and other things that are only implied by a command execution, but not actually provided as arguments. It's similar for Minecraft Spigot plugins: the context would typically contain the sender of the command.

Furthermore, contexts are mutable, meaning that attributes can be added before and during the mapping process. This is more relevant when using the bind framework, but it also allows the dispatcher in the core framework to put the parsed command input into the context, for example.

In CLI applications, there is little to no context to worry about, especially in such small applications that don't interact with the system in any way more than printing its results to the console. You can see a possible CLI usage of contexts in the bind framework guide, though.

But because CommandContext serves such an important role in mela-command, it should not be forgotten here, regardless of current relevance. Fortunately, it's as simple as putting values in a map and moreover very convenient:

CommandContext context = CommandContext.create();
// most entries follow this recommended pattern: type of the value, generic key (mostly string), value
context.put(int.class, "answer", 42); 
// that makes it easy and type safe to retrieve values
Optional<Integer> value = context.get(int.class, "answer"); // contexts return optionals!
int answer = value.orElseThrow(AssertionError::new);
System.out.println(answer); // 42

Internally, this type:key combination is realised using a composite key structure. You don't need to use those type-safe methods, but it is recommended, especially if the value is intended to be retrieved by users. As an alternative, there are still methods that take any Object as key and value.

Example Commands Implementation

Returning to our application, we now need to write a bunch of commands and I am going to use it to explain how to handle arguments in the core framework.

None of the commands takes complex arguments that require a lot of parsing. This is intentional, because the core framework API is made precisely for such simple requirements.

We're not going to write all of the commands in this guide, because many are very trivial. You can find the complete application here.

Instead, let's pick the "leetify" command as an example of the trivial commands and the "help" command because it's implementation is significantly different from the others.

Leetify

public class LeetifyCommand extends CommandCallableAdapter {

  private static final Map<Character, Character> REPLACEMENTS = 
      ImmutableMap.of('e', '3', 'a', '4', 's', '5', 'l', '1', 'o', '0');

  public LeetifyCommand() {
    super(
        ImmutableList.of("leetify", "leet"), 
        "Leetifies the given text, e.g. \"hello world\" -> \"h3110 w0r1d\"",
        "Type \"leetify\" and append your piece of text!", 
        "leetify/leet <text>"
    );
  }

  @Override
  public void call(CommandArguments arguments, CommandContext context) {
    while (arguments.hasNext()) {
      char next = arguments.next();
      System.out.print(REPLACEMENTS.getOrDefault(Character.toLowerCase(next), next));
    }
    System.out.println();
  }
}

CommandArguments#hasNext() returns true if there are still arguments left. The raw arguments are trimmed at creation time of CommandArguments and after calling any method that consumes an entire String, such as nextString() or nextScope(). In contrast to that, CommandArguments#next() does not skip leading whitespace. In our case, this means that the String is echoed exactly the way it was put in, except that the leet characters are replaced.

It's useful to know about the behaviour of CommandArguments. You can read more about it here.

Help

And this is our help command:

public class HelpCommand extends CommandCallableAdapter {

  private CommandGroup group;

  public HelpCommand() {
    super(
        ImmutableList.of("help"),
        "Displays general help or help for a command",
        "Type \"help\" to see all available commands. Type \"help <command>\""
            + " to receive help for a specific command.",
        "help <command>"
    );
  }

  @Override
  public void call(@Nonnull CommandArguments arguments, @Nonnull CommandContext context) {
    if (arguments.hasNext()) {
      CommandInput helpInput = CommandInput.parse(group, arguments.remaining());
      CommandCallable command = helpInput.getCommand();
      if (command == null) {
        System.out.println("Unknown command. Type just \"help\" to see a list of commands.");
      } else {
        System.out.printf(
            "Information about command \"%s\":%nAliases: %s%nDescription: %s%nUsage: %s%n%s%n",
            command.getPrimaryLabel(), command.getLabels(), command.getDescription(),
            command.getUsage(), command.getHelp());
      }
    } else {
      System.out.println("Here is a list of all available commands:");
      System.out.println();
      group.walk(this::displayCommands);
    }
  }

  private void displayCommands(CommandGroup group) {
    for (CommandCallable command : group.getCommands()) {
      System.out.printf("%s%s - %s%n",
          group.isRoot() ? "" : group.getQualifiedName() + " ", command.getPrimaryLabel(),
          command.getDescription());
    }
  }

  public void setGroup(CommandGroup group) {
    this.group = group;
  }
}

The reason why we needed to set a CommandGroup after instantiating HelpCommand now becomes apparent: we need it to parse the possible command input that is provided as an argument. And since command input can only be resolved in relation to a root group, we need to inject it here. Unfortunately, we can't pass it to the constructor, because that would make it a cyclic dependency: to create the group, we need a HelpCommand and to create a HelpCommand, we need the group.

The method CommandArguments#remaining() consumes all remaining characters of the arguments and returns them as a String. This is useful if there is only one String argument like in this case. or if all arguments left should be combined.

A CommandInput consists of three parts: a CommandGroup, a (nullable) CommandCallable and whatever is remaining as a String. Therefore, any input is a valid CommandInput, because the only thing that is required is a CommandGroup, and if no fitting group is found, it will be the root group. In our command, we want to respond differently based on whether the command is known or not, so for example:

  • help util would result in a CommandInput with the util group as the group component, no CommandCallable and an empty remaining String - therefore, we would be shown "unknown command"

  • help util encode foo bar would result in a CommandInput with the util group as the group component, the encode command as the CommandCallable component and a remaining String "foo bar" - therefore, we would be shown help for the decode command

  • help what ever this is would result in a CommandInput with the root group as the group component, no CommandCallable and a remaining String "what ever this is" - therefore, we would also be shown "unknown command"

If no arguments are provided at all, a list of commands is compiled.

As you can see, this is done using the CommandGroup#walk(Consumer) method. This method recurses through this group and its children and their children and so on depth-first. For each group, the given consumer is called. This allows us to make a flat list of commands by displaying the commands of each group (displayCommands(CommandGroup)).

The commands are then displayed in the following way:

[qualified group name] [primary command label] - [command description]

The qualified group name is the primary name of the group as well as the ones of its parents up to the root. For instance, if we invoked this for a group named baz that has the parent group bar, which in turn has the parent group foo, this would return "foo bar baz". This is in contrast to CommandGroup#getPrimaryName(), which only considers the current group and not its parents. In the previous example, that method would just return "baz".

We only use the qualified group name if the group is not a root. Otherwise, since root groups have no names, this would result in an ugly space before the name.

Since we don't have any nested groups below the root layer, we technically don't need to use the getQualifiedName() method, but should we decide to add more deeply nested groups, we would not need to adjust this code.

Wrapping up

When we are done writing all of the commands (again, you can view the entire code here), a session with the application might look like this:

Welcome to simple-cli. Type "help" to see a list of all available commands.
> help
Here is a list of all available commands:

version - Displays the version of the application
exit - Exits the application
help - Displays general help or help for a command
fun mock - "mocks" the text like the spongebob meme, e.g. "hello world" -> "hELlO WoRLd"
fun randomise - Randomises the characters in the middle of a word, e.g. "hello world" -> "hlelo wlord"
fun leetify - Leetifies the given text, e.g. "hello world" -> "h3110 w0r1d"
util decode - "decodes" the given binary numbers to a string again
util encode - "encodes" the given ASCII text in binary
> version
Current version is: 1.0.0
> help randomise
Unknown command. Type just "help" to see a list of commands.
> help fun randomise
Information about command "randomise":
Aliases: [randomise, rand]
Description: Randomises the characters in the middle of a word, e.g. "hello world" -> "hlelo wlord"
Usage: randomise/rand <text>
Type "fun randomise" and append your piece of text!
> fun rand is this text still readable? I don't know, to be brutally honest.
is tihs txet sitll rdaeleab? I dno't kown, to be blulraty hseton. 
> fun mock ah, yes, very interesting indeed
AH, Yes, vErY iNtErEStinG indEeD
> fun leetify I'm a hacker and I'm hacking your PC right now
I'm 4 h4ck3r 4nd I'm h4cking y0ur PC right n0w
> help util encode
Information about command "encode":
Aliases: [encode]
Description: "encodes" the given ASCII text in binary
Usage: encode <text>
Type "util encode" and append your piece of text!
> util encode does this work?
011001000110111101100101011100110010000001110100011010000110100101110011001000000111011101101111011100100110101100111111
> util decode 011001000110111101100101011100110010000001110100011010000110100101110011001000000111011101101111011100100110101100111111
does this work?
> hello
Unknown command. Use "help" for help.
> help hello
Unknown command. Type just "help" to see a list of commands.
> exit
Goodbye

Process finished with exit code 0