diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12ca10d641..749ff47d50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/lib_cli/build.gradle.kts b/lib_cli/build.gradle.kts new file mode 100644 index 0000000000..b1c912cf6f --- /dev/null +++ b/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) +} + diff --git a/lib_cli/src/main/x/cli.x b/lib_cli/src/main/x/cli.x new file mode 100644 index 0000000000..45440fc85a --- /dev/null +++ b/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 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)) { + 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; + + mixin Desc(String? text = Null) + into Parameter; + + static Map buildCatalog(TerminalApp app) { + Map cmdInfos = new ListMap(); + + scanCommands(() -> app, &app.actualClass, cmdInfos); + scanClasses(app.classes, cmdInfos); + return cmdInfos; + } + + static void scanCommands(function Object() instance, Class clz, Map 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 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(); + } + } +} \ No newline at end of file diff --git a/manualTests/src/main/x/TestSimple.x b/manualTests/src/main/x/TestSimple.x index e2a434c472..9e449ea8c9 100644 --- a/manualTests/src/main/x/TestSimple.x +++ b/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; + } } -} +} \ No newline at end of file diff --git a/xdk/build.gradle.kts b/xdk/build.gradle.kts index dbf1d11203..f9b5deda1a 100644 --- a/xdk/build.gradle.kts +++ b/xdk/build.gradle.kts @@ -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) diff --git a/xdk/settings.gradle.kts b/xdk/settings.gradle.kts index 4b41bda4e9..a0dc0cf74d 100644 --- a/xdk/settings.gradle.kts +++ b/xdk/settings.gradle.kts @@ -19,8 +19,9 @@ val xdkProjectPath = rootDir */ listOf( "lib_ecstasy", - "lib_collections", "lib_aggregate", + "lib_collections", + "lib_cli", "lib_crypto", "lib_net", "lib_json",