Skip to content

Commit

Permalink
Create a CLI library
Browse files Browse the repository at this point in the history
  • Loading branch information
Gene Gleyzer committed Mar 7, 2024
1 parent b83ba3a commit 1cce84d
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 16 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Expand Up @@ -52,6 +52,7 @@ gradle-release = { id = "net.researchgate.gradle-release", version.ref = "gradle
xdk = { group = "org.xtclang", name = "xdk", version.ref = "xdk" }
xdk-ecstasy = { group = "org.xtclang", name = "lib-ecstasy", version.ref = "xdk" }
xdk-aggregate = { group = "org.xtclang", name = "lib-aggregate", version.ref = "xdk" }
xdk-cli = { group = "org.xtclang", name = "lib-cli", version.ref = "xdk" }
xdk-collections = { group = "org.xtclang", name = "lib-collections", version.ref = "xdk" }
xdk-crypto = { group = "org.xtclang", name = "lib-crypto", version.ref = "xdk" }
xdk-json = { group = "org.xtclang", name = "lib-json", version.ref = "xdk" }
Expand Down
10 changes: 10 additions & 0 deletions lib_cli/build.gradle.kts
@@ -0,0 +1,10 @@
plugins {
id("org.xtclang.build.xdk.versioning")
alias(libs.plugins.xtc)
}

dependencies {
xdkJavaTools(libs.javatools)
xtcModule(libs.xdk.ecstasy)
}

286 changes: 286 additions & 0 deletions lib_cli/src/main/x/cli.x
@@ -0,0 +1,286 @@
/**
* Command Line Interface support.
*
* To use the CLI library, the application code needs to do the following:
* - annotate the module as a `TerminalApp`, for example:
* @TerminalApp("My commands")
* module MyCommands {...}
*
* - annotate any methods to be executed as a command with the `Command` annotation, for example:
* @Command("time", "Show current time")
* Time showTime() {
* @Inject Clock clock;
* return clock.now;
* }
*
* Note: all stateless API can be placed at the main module level. Any stateful API needs to placed
* inside of a class or service with a default constructor.
*
* In addition to all introspected commands, the TerminalApp mixin provides two built-in commands:
* - help [command-opt]
* - quit
*/
module cli.xtclang.org {

@Inject Console console;

mixin TerminalApp(String description = "",
String commandPrompt = "> ",
String messagePrefix = "# ",
)
into module {

typedef Map<String, CmdInfo> as Catalog;

/**
* The entry point.
*/
void run(String[] args) {
Catalog catalog = buildCatalog(this);
if (args.size == 0) {
runLoop(catalog);
} else {
runOnce(catalog, args);
}
}

/**
* Run a single command.
*/
void runOnce(Catalog catalog, String[] args) {
runCommand(args, catalog);
}

/**
* Read commands from the console and run them.
*/
void runLoop(Catalog catalog) {
while (True) {
console.print(commandPrompt, suppressNewline=True);

String command = console.readLine();

if (!runCommand(command.split(' ', trim=True), catalog)) {
return;
}
}
}

/**
* Find the specified command in the catalog.
*/
conditional CmdInfo findCommand(String command, Catalog catalog) {
for ((String name, CmdInfo info) : catalog) {
if (command.startsWith(name)) {
return True, info;
}
}
return False;
}

/**
* Run the specified command.
*
* @return False if the command is "quit"; True otherwise
*/
Boolean runCommand(String[] command, Catalog catalog) {
Int parts = command.size;
if (parts == 0) {
return True;
}

String head = command[0];
if (head == "quit") {
return False;
}

if (head == "help") {
printHelp(parts == 1 ? "" : command[1], catalog);
} else if (CmdInfo info := findCommand(head, catalog)) {
try {
Method method = info.method;
Parameter[] params = method.params;
if (method.requiredParamCount <= parts-1 <= params.size) {
Tuple args = Tuple:();
for (Int i : 1 ..< parts) {
String argStr = command[i];
Parameter param = params[i-1];
Type paramType = param.ParamType;
if (paramType.is(Type<Destringable>)) {
paramType.DataType argValue = new paramType.DataType(argStr);
args = args.add(argValue);
} else {
console.print($| Unsupported type "{paramType}" for parameter \
|"{param}"
);
return True;
}
}

Tuple result = method.invoke(info.target, args);

switch (result.size) {
case 0:
console.print();
break;
case 1:
console.print(result[0]);
break;
default:
for (Int i : 0 ..< result.size) {
console.print($"[{i}]={result[i]}");
}
break;
}
} else {
if (method.defaultParamCount == 0) {
console.print($" Required {params.size} arguments");

} else {
console.print($| Number of arguments should be between \
|{method.requiredParamCount} and {params.size}
);
}
}
} catch (Exception e) {
console.print($" Error: {e.message}");
}
} else {
console.print($" Unknown command: {head.quoted()}");
}
return True;
}

/**
* Print the instructions for the specified command or all the commands.
*/
void printHelp(String command, Catalog catalog) {
if (command == "") {
console.print($|{description == "" ? &this.actualClass.toString() : description}
|
|Commands are:
);
Int maxName = catalog.keys.map(s -> s.size)
.reduce(0, (s1, s2) -> s1.maxOf(s2));
for ((String name, CmdInfo info) : catalog) {
console.print($" {name.leftJustify(maxName+1)} {info.method.descr}");
}
} else if (CmdInfo info := findCommand(command, catalog)) {
Command method = info.method;
console.print($| {method.descr == "" ? info.method.name : method.descr}
);

Parameter[] params = method.params;
Int paramCount = params.size;
if (paramCount > 0) {
console.print("Parameters:");

String[] names = params.map(p -> {
assert String name := p.hasName();
return p.defaultValue() ? $"{name} (opt)" : name;
}).toArray();

Int maxName = names.map(n -> n.size)
.reduce(0, (s1, s2) -> s1.maxOf(s2));
for (Int i : 0 ..< paramCount) {
Parameter param = params[i];
console.print($| {names[i].leftJustify(maxName)} \
|{param.is(Desc) ? param.text : ""}
);
}
}
} else {
console.print($" Unknown command: {command.quoted()}");
}
}

void printResult(Tuple result) {
Int count = result.size;
switch (count) {
case 0:
break;

case 1:
console.print($" {result[0]}");
break;

default:
for (Int i : 0 ..< count) {
console.print($" [i]={result[i]}");
}
break;
}
}

/**
* This method is meant to be used by the CLI classes to differentiate the output of the
* framework itself and of its users.
*/
void print(String s) {
console.print($"{messagePrefix} {s}");
}
}

mixin Command(String cmd = "", String descr = "")
into Method<Object>;

mixin Desc(String? text = Null)
into Parameter<Object>;

static Map<String, CmdInfo> buildCatalog(TerminalApp app) {
Map<String, CmdInfo> cmdInfos = new ListMap();

scanCommands(() -> app, &app.actualClass, cmdInfos);
scanClasses(app.classes, cmdInfos);
return cmdInfos;
}

static void scanCommands(function Object() instance, Class clz, Map<String, CmdInfo> catalog) {
Type type = clz.PublicType;

for (Method method : type.methods) {
if (method.is(Command)) {
String cmd = method.cmd == "" ? method.name : method.cmd;
if (catalog.contains(cmd)) {
throw new IllegalState($|A duplicate command "{cmd}" by the method "{method}"
);
}
catalog.put(cmd, new CmdInfo(instance(), method));
}
}
}

static void scanClasses(Class[] classes, Map<String, CmdInfo> catalog) {

static class Instance(Class clz) {
@Lazy Object get.calc() {
if (Object single := clz.isSingleton()) {
return single;
}
Type type = clz.PublicType;
if (function Object () constructor := type.defaultConstructor()) {
return constructor();
}
throw new IllegalState($|default constructor is missing for "{clz}"
);
}
}

for (Class clz : classes) {
if (clz.annotatedBy(Abstract)) {
continue;
}

Instance instance = new Instance(clz);

scanCommands(() -> instance.get, clz, catalog);
}
}

class CmdInfo(Object target, Command method) {
@Override
String toString() {
return method.toString();
}
}
}
44 changes: 29 additions & 15 deletions manualTests/src/main/x/TestSimple.x
@@ -1,20 +1,34 @@
@TerminalApp("Simple command tool test")
module TestSimple {
@Inject Console console;
package cli import cli.xtclang.org;

void run() {
Type t = Test;
console.print("Multimethods:");
console.print($"{t.multimethods.keys.toString(sep="\n")}");
console.print("Methods:");
console.print($"{t.methods.toString(sep="\n")}"); // the order used to be random
import cli.*;

// ----- stateless API -------------------------------------------------------------------------

@Command("time", "Show current time")
Time showTime() {
@Inject Clock clock;
return clock.now;
}

@Command("dirs", "Show home current and temp directories")
(Directory, Directory, Directory) showDirs() {
@Inject Directory curDir;
@Inject Directory homeDir;
@Inject Directory tmpDir;
return curDir, homeDir, tmpDir;
}

class Test {
void f1(Int i);
void f1(String s);
void f2(String s);
void f2(Boolean f);
void f3(Boolean f);
void f4(Byte b);
// ----- stateful API --------------------------------------------------------------------------

service Stateful {
Int count;

@Command("inc", "Increment the count")
Int addCount(@Desc("increment value") Int increment = 1) {
count += increment;
return count;
}
}
}
}
1 change: 1 addition & 0 deletions xdk/build.gradle.kts
Expand Up @@ -47,6 +47,7 @@ dependencies {
xdkJavaTools(libs.javatools)
xtcModule(libs.xdk.ecstasy)
xtcModule(libs.xdk.aggregate)
xtcModule(libs.xdk.cli)
xtcModule(libs.xdk.collections)
xtcModule(libs.xdk.crypto)
xtcModule(libs.xdk.json)
Expand Down
3 changes: 2 additions & 1 deletion xdk/settings.gradle.kts
Expand Up @@ -19,8 +19,9 @@ val xdkProjectPath = rootDir
*/
listOf(
"lib_ecstasy",
"lib_collections",
"lib_aggregate",
"lib_collections",
"lib_cli",
"lib_crypto",
"lib_net",
"lib_json",
Expand Down

0 comments on commit 1cce84d

Please sign in to comment.