diff --git a/Makefile b/Makefile index d9b4d8e..97b34a8 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,9 @@ CFLAGS = $(CFLAGS_LANG) -g -Werror=return-type -fsanitize=undefined,alignment -f CFLAGS_TEST = -DSP_IMPLEMENTATION -DSP_TEST_IMPLEMENTATION -I. -Itest/tools -Itest CFLAGS_BENCH = $(CFLAGS_LANG) -g -Werror=return-type -O2 -DSP_IMPLEMENTATION -DUBENCH_ENABLE_PERF_COUNTERS -I. -Itest/bench -Itest/tools -TESTS = amalg app array asset etc cv env format fmon fs glob ht io math process ps rb str thread time mem prompt leak +TESTS = amalg app array asset cli etc cv env format fmon fs glob ht io math process ps rb str thread time mem prompt leak BENCHES = glob heap -EXAMPLES = app array format hash_table io zero_copy ls palette prompt prompt_fancy signal wc +EXAMPLES = app array cli format hash_table io zero_copy ls palette prompt prompt_fancy signal wc TRIPLES = \ x86_64-linux-none x86_64-linux-gnu x86_64-linux-musl \ aarch64-linux-none aarch64-linux-gnu aarch64-linux-musl \ diff --git a/example/cli.c b/example/cli.c new file mode 100644 index 0000000..150c338 --- /dev/null +++ b/example/cli.c @@ -0,0 +1,333 @@ +/* + This is the CLI for a small, fake package manager. It's meant to exercise + most of the library. In particular, it shows how to: + - Define your CLI declaratively and hook up handlers + - Add commands (e.g. pkg add) + - Add options (e.g. --foo or --bar=BAZ) to your commands, and give them briefs (e.g. -f) + - Add arguments (e.g. pkg add some_dependency) + - Add nested commands (e.g. pkg tool run sqlite) + + And finally, it'll show you the simple API which automatically prints help + text and parse errors, and then the advanced API which lets you do + whatever you want. +*/ +#define SP_IMPLEMENTATION +#include "sp.h" +#include "sp/sp_cli.h" + +typedef struct { + bool verbose; + struct { + const c8* package; + const c8* version; + bool force; + } add; + struct { + const c8* target; + s64 jobs; + } build; + const c8* tool; +} pkg_t; + +////////////// +// HANDLERS // +////////////// +sp_cli_result_t pkg_add(sp_cli_t* cli) { + pkg_t* pkg = sp_cast(pkg_t*, cli->user_data); + sp_log("adding {.cyan} {.yellow}", sp_fmt_cstr(pkg->add.package), sp_fmt_cstr(pkg->add.version)); + if (pkg->add.force) { + sp_log("reinstalling from scratch"); + } + return SP_CLI_OK; +} + +sp_cli_result_t pkg_build(sp_cli_t* cli) { + pkg_t* pkg = sp_cast(pkg_t*, cli->user_data); + if (pkg->build.jobs < 1) { + sp_cli_log_error("Jobs must be >= 1 (got {.cyan})", sp_fmt_int(pkg->build.jobs)); + return SP_CLI_ERR; + } + + const c8* target = pkg->build.target ? pkg->build.target : "all"; + sp_log("Building {.cyan} with {.yellow} jobs", sp_fmt_cstr(target), sp_fmt_int(pkg->build.jobs)); + return SP_CLI_OK; +} + +sp_cli_result_t pkg_tool_run(sp_cli_t* cli) { + pkg_t* pkg = sp_cast(pkg_t*, cli->user_data); + sp_log("Running tool: {.cyan}", sp_fmt_cstr(pkg->tool)); + sp_for(it, cli->num_rest) { + sp_log(" args[{}] {.gray}", sp_fmt_uint(it), sp_fmt_cstr(cli->rest[it])); + } + return SP_CLI_OK; +} + +s32 run(s32 num_args, const c8** args) { + pkg_t pkg = { + .build = { .jobs = 1 }, + }; + + //////////////////// + // CLI DEFINITION // + //////////////////// + // + // You define your CLI as data; what commands it has, what arguments or + // named options they take, descriptions and help text. Each command has + // a handler. This is the basic shape of the data: + // + // sp_cli_cmd_t root = { + // "pkg", "description", "summary", + // opts[], args[], + // commands[] // Same thing, nested + // } + // + // I prefer to make a typed, nested struct so I can initialize everything + // in one initializer. This isn't required; if you don't do it this way, + // you'll have to declare any subcommands separately, since you'll have + // no way to reference them in the parent's initializer + // + // I also prefer to be verbose. I put every field on its own line, and I + // always specify the key. If this is too noisy, search for "@compact" to + // see a tighter version + struct { + struct { + sp_cli_cmd_t run; + } tools; + sp_cli_cmd_t tool; + + sp_cli_cmd_t add; + sp_cli_cmd_t build; + } c = { + .tools = { + .run = { + .name = "run", + .summary = "Run a binary from a package", + .args = { + { + .name = "name", + .summary = "The binary to run", + .ptr = &pkg.tool, + }, + { + .name = "args", + .kind = SP_CLI_ARG_REST, + .summary = "Arguments passed to the binary", + }, + }, + .handler = pkg_tool_run, + }, + }, + .tool = { + .name = "tool", + .summary = "Run and manage binaries defined by packages", + .commands = { + &c.tools.run + }, + }, + .add = { + .name = "add", + .summary = "Add a package to the project", + .opts = { + { + .brief = "f", + .name = "force", + .summary = "Force reinstall even if already installed", + .ptr = &pkg.add.force, + }, + }, + .args = { + { + .name = "package", + .summary = "The package to add", + .ptr = &pkg.add.package, + }, + { + .name = "version", + .kind = SP_CLI_ARG_OPTIONAL, + .summary = "Version to add", + .ptr = &pkg.add.version, + }, + }, + .handler = pkg_add, + }, + .build = { + .name = "build", + .summary = "Build the project from source", + .opts = { + { + .brief = "j", + .name = "jobs", + .kind = SP_CLI_OPT_INTEGER, + .summary = "Number of parallel jobs", + .placeholder = "N", + .ptr = &pkg.build.jobs, + }, + { + .name = "target", + .kind = SP_CLI_OPT_STRING, + .summary = "Build only the named target", + .placeholder = "NAME", + .ptr = &pkg.build.target, + }, + }, + .handler = pkg_build, + }, + }; + + sp_cli_cmd_t root = { + .name = "pkg", + .summary = "An example package manager", + .opts = { + { + .brief = "v", + .name = "verbose", + .summary = "Show verbose output", + .ptr = &pkg.verbose, + }, + }, + .commands = { &c.add, &c.build, &c.tool }, + }; + + // Unless you have a reason not to, invoke the CLI like this. The cli owns + // no memory and needs no cleanup; everything it binds (e.g. pkg.add.package) + // points into args, so it's valid for the life of the program. + return sp_cli_main(&root, num_args, args, &pkg); + + /* + ///////////////////// + // HANDLING ERRORS // + ///////////////////// + // If you'd like to specifically handle the return code, but + // would otherwise like the library to print usage and parse + // errors for you, you can call sp_cli_run() directly. + // + // sp_cli_main() is a wrapper over sp_cli_run(), just like this + // example, which collapses success states. + + switch (sp_cli_run(&root, num_args, args, &pkg)) { + case SP_CLI_OK: return 0; + case SP_CLI_HELP: return 0; + case SP_CLI_ERR: return 1; + case SP_CLI_CONTINUE: { + // Draw the rest of the owl + return 0; + } + } + sp_unreachable_return(1); + */ + + /* + //////////////////// + // ADVANCED USAGE // + //////////////////// + // If you want full control, it's still pretty simple. All you're doing + // differently is: + // - Explicitly passing args[1...] + // - Invoking the parser on your CLI descriptor + // - Handling the parse result + // - Invoking the dispatch function on the parsed result + // + // Parse errors are structured data (cli.err); render them with + // sp_cli_err_write(), or switch on cli.err.kind and do something else + // entirely. Handler errors never pass through the cli: handlers print + // their own and return SP_CLI_ERR. + + sp_io_stream_writer_t out = sp_io_get_std_out(); + sp_io_stream_writer_t err = sp_io_get_std_err(); + + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = &root, + .args = args + 1, + .num_args = num_args ? sp_cast(u32, num_args - 1) : 0, + .user_data = &pkg, + }); + + switch (cli.status) { + case SP_CLI_HELP: { + sp_cli_usage_write(&out.base, cli.cmd); + return 0; + } + case SP_CLI_ERR: { + sp_fmt_io(&err.base, "{.red}: ", sp_fmt_cstr("error")); + sp_cli_err_write(&err.base, &cli.err); + sp_fmt_io(&err.base, "\n"); + sp_cli_usage_write(&err.base, cli.cmd); + return 1; + } + case SP_CLI_OK: break; + case SP_CLI_CONTINUE: break; + } + + switch (sp_cli_dispatch(&cli)) { + case SP_CLI_OK: break; + case SP_CLI_HELP: break; + case SP_CLI_CONTINUE: break; + case SP_CLI_ERR: { + if (cli.err.kind != SP_CLI_ERR_NONE) { + sp_fmt_io(&err.base, "{.red}: ", sp_fmt_cstr("error")); + sp_cli_err_write(&err.base, &cli.err); + sp_fmt_io(&err.base, "\n"); + } + return 1; + } + } + return 0; + */ + + /* + ///////////// + // COMPACT // + ///////////// + // @compact + // + // This is identical to the other one, just more compact. + + struct { + struct { + sp_cli_cmd_t run; + } tools; + sp_cli_cmd_t tool; + + sp_cli_cmd_t add; + sp_cli_cmd_t build; + } compact = { + .tools = { + .run = { + "run", "Run a binary from a package", + .args = { + { "name", SP_CLI_ARG_REQUIRED, "The binary to run", &pkg.tool }, + { "args", SP_CLI_ARG_REST, "Arguments passed to the binary" } + }, + .handler = pkg_tool_run, + }, + }, + .tool = { + "tool", "Run and manage binaries defined by packages", + .commands = { &compact.tools.run }, + }, + .add = { + "add", "Add a package to the project", + .opts = { + { "f", "force", SP_CLI_OPT_BOOLEAN, "Force reinstall even if already installed", SP_CLI_NO_PLACEHOLDER, &pkg.add.force }, + }, + .args = { + { "package", SP_CLI_ARG_REQUIRED, "The package to add", &pkg.add.package }, + { "version", SP_CLI_ARG_OPTIONAL, "Version to add", &pkg.add.version }, + }, + .handler = pkg_add, + }, + .build = { + "build", "Build the project from source", + .opts = { + { "j", "jobs", SP_CLI_OPT_INTEGER, "Number of parallel jobs", "N", &pkg.build.jobs }, + { SP_NULLPTR, "target", SP_CLI_OPT_STRING, "Build only the named target", "NAME", &pkg.build.target }, + }, + .handler = pkg_build, + }, + }; + */ + + + +} +SP_MAIN(run) diff --git a/example/io.c b/example/io.c index 4893051..879706f 100644 --- a/example/io.c +++ b/example/io.c @@ -30,8 +30,7 @@ s32 run(s32 num_args, const c8** args) { sp_io_file_reader_close(&r); // You can also format directly to stdout - sp_io_stream_writer_t fw = sp_zero; - sp_io_stream_writer_from_fd(&fw, sp_sys_stdout, SP_IO_CLOSE_MODE_NONE); + sp_io_stream_writer_t fw = sp_io_get_std_out(); sp_fmt_io(&fw.base, "hello, {.cyan}", sp_fmt_cstr("stdout")); sp_io_write(&fw.base, "\n", 1, SP_NULLPTR); sp_io_stream_writer_close(&fw); diff --git a/sp.h b/sp.h index f20f908..6c62666 100644 --- a/sp.h +++ b/sp.h @@ -2933,6 +2933,8 @@ SP_API sp_str_r sp_fmt_buf(c8* buffer, u64 len, const c8* fmt, ...); SP_API sp_str_r sp_fmt_buf_v(c8* buffer, u64 len, sp_str_t fmt, va_list args); SP_API sp_err_t sp_fmt_io(sp_io_writer_t* io, const c8* fmt, ...); SP_API sp_err_t sp_fmt_io_v(sp_io_writer_t* io, sp_str_t fmt, va_list args); +SP_API sp_err_t sp_fmt_std_out(const c8* fmt, ...); +SP_API sp_err_t sp_fmt_std_err(const c8* fmt, ...); typedef enum { SP_FMT_ALIGN_NONE, @@ -3432,8 +3434,8 @@ SP_API sp_err_t sp_io_dyn_mem_writer_close(sp_io_dyn_mem_writer_t* w); SP_API sp_str_t sp_io_dyn_mem_writer_as_str(sp_io_dyn_mem_writer_t* w); SP_API const c8* sp_io_dyn_mem_writer_as_cstr(sp_io_dyn_mem_writer_t* w); -SP_API void sp_io_get_std_out(sp_io_stream_writer_t* io); -SP_API void sp_io_get_std_err(sp_io_stream_writer_t* io); +SP_API sp_io_stream_writer_t sp_io_get_std_out(); +SP_API sp_io_stream_writer_t sp_io_get_std_err(); // ███████████ ███████████ ███████ █████████ ██████████ █████████ █████████ @@ -9653,8 +9655,7 @@ void sp_assert_f(sp_str_t file, sp_str_t line, sp_str_t func, sp_str_t expr, boo if (cond) return; #if SP_ASSERT_ENABLED(SP_ASSERT_LOG) - sp_io_stream_writer_t io = sp_zero; - sp_io_stream_writer_from_fd(&io, sp_sys_stderr, SP_IO_CLOSE_MODE_NONE); + sp_io_stream_writer_t io = sp_io_get_std_err(); sp_fmt_io( &io.base, "{.red} {}:{.gray}:{.yellow}{.yellow} {}", @@ -14229,12 +14230,16 @@ sp_err_t sp_io_pad(sp_io_writer_t* writer, u64 size, u64* bytes_written) { return result; } -void sp_io_get_std_out(sp_io_stream_writer_t* io) { - sp_io_stream_writer_from_fd(io, sp_sys_stdout, SP_IO_CLOSE_MODE_NONE); +sp_io_stream_writer_t sp_io_get_std_out() { + sp_io_stream_writer_t io = sp_zero; + sp_io_stream_writer_from_fd(&io, sp_sys_stdout, SP_IO_CLOSE_MODE_NONE); + return io; } -void sp_io_get_std_err(sp_io_stream_writer_t* io) { - sp_io_stream_writer_from_fd(io, sp_sys_stderr, SP_IO_CLOSE_MODE_NONE); +sp_io_stream_writer_t sp_io_get_std_err() { + sp_io_stream_writer_t io = sp_zero; + sp_io_stream_writer_from_fd(&io, sp_sys_stderr, SP_IO_CLOSE_MODE_NONE); + return io; } ///////// @@ -14841,6 +14846,24 @@ sp_err_t sp_fmt_io(sp_io_writer_t* io, const c8* fmt, ...) { return result; } +sp_err_t sp_fmt_std_out(const c8* fmt, ...) { + va_list args; + va_start(args, fmt); + sp_io_stream_writer_t io = sp_io_get_std_out(); + sp_err_t result = sp_fmt_io_v(&io.base, sp_cstr_as_str(fmt), args); + va_end(args); + return result; +} + +sp_err_t sp_fmt_std_err(const c8* fmt, ...) { + va_list args; + va_start(args, fmt); + sp_io_stream_writer_t io = sp_io_get_std_err(); + sp_err_t result = sp_fmt_io_v(&io.base, sp_cstr_as_str(fmt), args); + va_end(args); + return result; +} + sp_str_r sp_fmt_buf(c8* buffer, u64 len, const c8* fmt, ...) { va_list args; va_start(args, fmt); diff --git a/sp/sp_cli.h b/sp/sp_cli.h new file mode 100644 index 0000000..79cda0d --- /dev/null +++ b/sp/sp_cli.h @@ -0,0 +1,716 @@ +#ifndef SP_CLI_H +#define SP_CLI_H + +#include "sp.h" + +#ifndef SP_CLI_MAX_OPTS + #define SP_CLI_MAX_OPTS 16 +#endif + +#ifndef SP_CLI_MAX_ARGS + #define SP_CLI_MAX_ARGS 8 +#endif + +#ifndef SP_CLI_MAX_COMMANDS + #define SP_CLI_MAX_COMMANDS 16 +#endif + +#ifndef SP_CLI_MAX_LABEL + #define SP_CLI_MAX_LABEL 64 +#endif + +#define SP_CLI_ARG_KIND(X) \ + X(SP_CLI_ARG_REQUIRED, "required") \ + X(SP_CLI_ARG_OPTIONAL, "optional") \ + X(SP_CLI_ARG_REST, "rest") + +typedef enum { + SP_CLI_ARG_KIND(SP_X_NAMED_ENUM_DEFINE) +} sp_cli_arg_kind_t; + +#define SP_CLI_OPT_KIND(X) \ + X(SP_CLI_OPT_BOOLEAN, "boolean") \ + X(SP_CLI_OPT_STRING, "string") \ + X(SP_CLI_OPT_INTEGER, "integer") + +typedef enum { + SP_CLI_OPT_KIND(SP_X_NAMED_ENUM_DEFINE) +} sp_cli_opt_kind_t; + +#define SP_CLI_RESULT(X) \ + X(SP_CLI_OK, "ok") \ + X(SP_CLI_ERR, "error") \ + X(SP_CLI_HELP, "help") \ + X(SP_CLI_CONTINUE, "continue") + +typedef enum { + SP_CLI_RESULT(SP_X_NAMED_ENUM_DEFINE) +} sp_cli_result_t; + +#define SP_CLI_ERR_KIND(X) \ + X(SP_CLI_ERR_NONE, "none") \ + X(SP_CLI_ERR_UNKNOWN_OPT, "unknown_opt") \ + X(SP_CLI_ERR_UNKNOWN_BRIEF, "unknown_brief") \ + X(SP_CLI_ERR_INVALID_VALUE, "invalid_value") \ + X(SP_CLI_ERR_MISSING_VALUE, "missing_value") \ + X(SP_CLI_ERR_MISSING_ARG, "missing_arg") \ + X(SP_CLI_ERR_UNEXPECTED_ARG, "unexpected_arg") \ + X(SP_CLI_ERR_UNKNOWN_COMMAND, "unknown_command") \ + X(SP_CLI_ERR_NO_HANDLER, "no_handler") + +typedef enum { + SP_CLI_ERR_KIND(SP_X_NAMED_ENUM_DEFINE) +} sp_cli_err_kind_t; + +typedef struct { + sp_cli_err_kind_t kind; + sp_str_t name; + sp_str_t value; +} sp_cli_err_t; + +typedef struct sp_cli sp_cli_t; +typedef struct sp_cli_cmd sp_cli_cmd_t; + +SP_TYPEDEF_FN(sp_cli_result_t, sp_cli_handler_t, sp_cli_t*); + +typedef struct { + const c8* name; + sp_cli_arg_kind_t kind; + const c8* summary; + const c8** ptr; +} sp_cli_arg_t; + +typedef struct { + const c8* brief; + const c8* name; + sp_cli_opt_kind_t kind; + const c8* summary; + const c8* placeholder; + void* ptr; +} sp_cli_opt_t; + +#define SP_CLI_NO_OPTS sp_zero +#define SP_CLI_NO_ARGS sp_zero +#define SP_CLI_NO_CMDS sp_zero +#define SP_CLI_NO_PLACEHOLDER SP_NULLPTR + +struct sp_cli_cmd { + const c8* name; + const c8* summary; + sp_cli_opt_t opts [SP_CLI_MAX_OPTS]; + sp_cli_arg_t args [SP_CLI_MAX_ARGS]; + sp_cli_cmd_t* commands [SP_CLI_MAX_COMMANDS]; + sp_cli_handler_t handler; +}; + +typedef struct { + sp_cli_cmd_t* root; + const c8** args; + u32 num_args; + void* user_data; +} sp_cli_desc_t; + +struct sp_cli { + void* user_data; + sp_cli_result_t status; + sp_cli_err_t err; + sp_cli_cmd_t* cmd; + const c8** rest; + u32 num_rest; +}; + +SP_API sp_str_t sp_cli_arg_kind_to_str(sp_cli_arg_kind_t kind); +SP_API sp_str_t sp_cli_opt_kind_to_str(sp_cli_opt_kind_t kind); +SP_API sp_str_t sp_cli_result_to_str(sp_cli_result_t result); +SP_API sp_str_t sp_cli_err_kind_to_str(sp_cli_err_kind_t kind); +SP_API sp_cli_t sp_cli_parse(sp_cli_desc_t desc); +SP_API sp_cli_result_t sp_cli_dispatch(sp_cli_t* cli); +SP_API sp_cli_result_t sp_cli_run(sp_cli_cmd_t* root, s32 num_args, const c8** args, void* user_data); +SP_API s32 sp_cli_main(sp_cli_cmd_t* root, s32 num_args, const c8** args, void* user_data); +SP_API void sp_cli_usage_write(sp_io_writer_t* io, sp_cli_cmd_t* cmd); +SP_API void sp_cli_err_write(sp_io_writer_t* io, sp_cli_err_t* err); +SP_API void sp_cli_log_error(const c8* fmt, ...); +SP_API void sp_cli_log_error_v(sp_str_t fmt, va_list args); + +#endif // SP_CLI_H + +#if defined(SP_IMPLEMENTATION) && !defined(SP_CLI_IMPLEMENTATION) + #define SP_CLI_IMPLEMENTATION +#endif + +#if defined(SP_CLI_IMPLEMENTATION) && !defined(SP_CLI_IMPLEMENTED) +#define SP_CLI_IMPLEMENTED + +typedef struct sp_cli_scope { + sp_cli_cmd_t* cmd; + struct sp_cli_scope* parent; +} sp_cli_scope_t; + +typedef struct { + sp_cli_t* cli; + const c8** args; + u32 num_args; + u32 it; + bool raw; + bool help; + const c8* positionals [SP_CLI_MAX_ARGS]; + u32 num_positionals; +} sp_cli_parser_t; + +SP_PRIVATE sp_str_t sp_cli_str(const c8* cstr) { + return cstr ? sp_str_view(cstr) : sp_zero_s(sp_str_t); +} + +SP_PRIVATE sp_err_t sp_cli_fail(sp_cli_t* cli, sp_cli_err_t err) { + cli->err = err; + return SP_ERR; +} + +SP_PRIVATE u32 sp_cli_num_opts(sp_cli_cmd_t* cmd) { + u32 num = 0; + sp_carr_for(cmd->opts, it) { + if (!cmd->opts[it].name) break; + num++; + } + return num; +} + +SP_PRIVATE u32 sp_cli_num_fixed_args(sp_cli_cmd_t* cmd) { + u32 num = 0; + sp_carr_for(cmd->args, it) { + if (!cmd->args[it].name) break; + if (cmd->args[it].kind == SP_CLI_ARG_REST) break; + num++; + } + return num; +} + +SP_PRIVATE bool sp_cli_has_commands(sp_cli_cmd_t* cmd) { + return cmd->commands[0] != SP_NULLPTR; +} + +SP_PRIVATE bool sp_cli_has_rest(sp_cli_cmd_t* cmd) { + sp_carr_for(cmd->args, it) { + if (!cmd->args[it].name) break; + if (cmd->args[it].kind == SP_CLI_ARG_REST) return true; + } + return false; +} + +SP_PRIVATE bool sp_cli_done(sp_cli_parser_t* parser) { + return parser->it >= parser->num_args; +} + +SP_PRIVATE sp_str_t sp_cli_peek(sp_cli_parser_t* parser) { + if (sp_cli_done(parser)) return sp_zero_s(sp_str_t); + return sp_str_view(parser->args[parser->it]); +} + +SP_PRIVATE void sp_cli_eat(sp_cli_parser_t* parser) { + parser->it++; +} + +SP_PRIVATE bool sp_cli_at_opt(sp_cli_parser_t* parser) { + sp_str_t arg = sp_cli_peek(parser); + return arg.len > 1 && sp_str_at(arg, 0) == '-'; +} + +SP_PRIVATE sp_cli_opt_t* sp_cli_find_opt(sp_cli_scope_t* scope, sp_str_t name) { + for (sp_cli_scope_t* it = scope; it; it = it->parent) { + sp_carr_for(it->cmd->opts, i) { + sp_cli_opt_t* opt = &it->cmd->opts[i]; + if (!opt->name) break; + if (sp_str_equal_cstr(name, opt->name)) return opt; + } + } + return SP_NULLPTR; +} + +SP_PRIVATE sp_cli_opt_t* sp_cli_find_brief(sp_cli_scope_t* scope, c8 brief) { + for (sp_cli_scope_t* it = scope; it; it = it->parent) { + sp_carr_for(it->cmd->opts, i) { + sp_cli_opt_t* opt = &it->cmd->opts[i]; + if (!opt->name) break; + if (opt->brief && opt->brief[0] == brief) return opt; + } + } + return SP_NULLPTR; +} + +SP_PRIVATE sp_err_t sp_cli_assign(sp_cli_parser_t* parser, sp_cli_opt_t* opt, sp_str_t value) { + switch (opt->kind) { + case SP_CLI_OPT_BOOLEAN: { + bool parsed = true; + if (!sp_str_empty(value) && !sp_parse_bool_ex(value, &parsed)) { + return sp_cli_fail(parser->cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_INVALID_VALUE, + .name = sp_cli_str(opt->name), + .value = value, + }); + } + if (opt->ptr) *sp_cast(bool*, opt->ptr) = parsed; + break; + } + case SP_CLI_OPT_STRING: { + // Every value is either a whole element of desc.args or a NUL-terminated + // tail of one (the text after '=' or after a brief cluster), so string + // options borrow the args array directly instead of copying. + if (opt->ptr) *sp_cast(const c8**, opt->ptr) = value.data ? value.data : ""; + break; + } + case SP_CLI_OPT_INTEGER: { + s64 parsed = 0; + if (!sp_parse_s64_ex(value, &parsed)) { + return sp_cli_fail(parser->cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_INVALID_VALUE, + .name = sp_cli_str(opt->name), + .value = value, + }); + } + if (opt->ptr) *sp_cast(s64*, opt->ptr) = parsed; + break; + } + } + return SP_OK; +} + +SP_PRIVATE sp_err_t sp_cli_value(sp_cli_parser_t* parser, sp_cli_opt_t* opt, sp_str_t* value) { + sp_str_t next = sp_cli_peek(parser); + if (sp_cli_done(parser) || sp_str_starts_with(next, sp_str_lit("--"))) { + return sp_cli_fail(parser->cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_MISSING_VALUE, + .name = sp_cli_str(opt->name), + }); + } + *value = next; + sp_cli_eat(parser); + return SP_OK; +} + +SP_PRIVATE sp_err_t sp_cli_parse_long(sp_cli_parser_t* parser, sp_cli_scope_t* scope) { + sp_str_t body = sp_str_strip_left(sp_cli_peek(parser), sp_str_lit("--")); + sp_cli_eat(parser); + + sp_str_t name = body; + sp_str_t value = sp_zero_s(sp_str_t); + bool has_value = false; + sp_str_for(body, it) { + if (sp_str_at(body, it) == '=') { + name = sp_str_prefix(body, it); + value = sp_str_suffix(body, body.len - (it + 1)); + has_value = true; + break; + } + } + + sp_cli_opt_t* opt = sp_cli_find_opt(scope, name); + if (!opt) { + if (sp_str_equal(name, sp_str_lit("help"))) { + parser->help = true; + return SP_OK; + } + return sp_cli_fail(parser->cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_UNKNOWN_OPT, + .name = name, + }); + } + + if (!has_value && opt->kind != SP_CLI_OPT_BOOLEAN) { + sp_try(sp_cli_value(parser, opt, &value)); + } + + return sp_cli_assign(parser, opt, value); +} + +SP_PRIVATE sp_err_t sp_cli_parse_briefs(sp_cli_parser_t* parser, sp_cli_scope_t* scope) { + sp_str_t cluster = sp_str_strip_left(sp_cli_peek(parser), sp_str_lit("-")); + sp_cli_eat(parser); + + sp_str_for(cluster, it) { + c8 brief = sp_str_at(cluster, it); + sp_cli_opt_t* opt = sp_cli_find_brief(scope, brief); + if (!opt) { + if (brief == 'h') { + parser->help = true; + return SP_OK; + } + return sp_cli_fail(parser->cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_UNKNOWN_BRIEF, + .name = sp_str_sub(cluster, it, 1), + }); + } + + sp_str_t value = sp_zero_s(sp_str_t); + if (opt->kind != SP_CLI_OPT_BOOLEAN) { + if (it + 1 < cluster.len) { + value = sp_str_suffix(cluster, cluster.len - (it + 1)); + } + else { + sp_try(sp_cli_value(parser, opt, &value)); + } + return sp_cli_assign(parser, opt, value); + } + + sp_try(sp_cli_assign(parser, opt, value)); + } + + return SP_OK; +} + +SP_PRIVATE sp_err_t sp_cli_parse_cmd(sp_cli_parser_t* parser, sp_cli_scope_t scope) { + sp_cli_t* cli = parser->cli; + sp_cli_cmd_t* cmd = scope.cmd; + cli->cmd = cmd; + + u32 num_fixed = sp_cli_num_fixed_args(cmd); + + while (!sp_cli_done(parser)) { + sp_str_t arg = sp_cli_peek(parser); + + if (!parser->raw && sp_str_equal(arg, sp_str_lit("--"))) { + sp_cli_eat(parser); + parser->raw = true; + } + else if (!parser->raw && sp_str_starts_with(arg, sp_str_lit("--"))) { + sp_try(sp_cli_parse_long(parser, &scope)); + if (parser->help) return SP_OK; + } + else if (!parser->raw && sp_cli_at_opt(parser)) { + sp_try(sp_cli_parse_briefs(parser, &scope)); + if (parser->help) return SP_OK; + } + else { + if (sp_cli_has_commands(cmd)) break; + if (parser->num_positionals >= num_fixed && sp_cli_has_rest(cmd)) { + cli->num_rest = parser->num_args - parser->it; + cli->rest = parser->args + parser->it; + parser->it = parser->num_args; + break; + } + if (parser->num_positionals >= SP_CLI_MAX_ARGS || parser->num_positionals >= num_fixed) { + return sp_cli_fail(cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_UNEXPECTED_ARG, + .value = arg, + }); + } + parser->positionals[parser->num_positionals++] = parser->args[parser->it]; + sp_cli_eat(parser); + } + } + + sp_carr_for(cmd->args, it) { + sp_cli_arg_t* arg = &cmd->args[it]; + if (!arg->name) break; + + if (it < parser->num_positionals) { + if (arg->ptr) *arg->ptr = parser->positionals[it]; + } + else if (arg->kind == SP_CLI_ARG_REQUIRED) { + return sp_cli_fail(cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_MISSING_ARG, + .name = sp_cli_str(arg->name), + }); + } + } + + if (sp_cli_has_commands(cmd)) { + if (!sp_cli_done(parser)) { + sp_str_t name = sp_cli_peek(parser); + sp_carr_for(cmd->commands, it) { + sp_cli_cmd_t* sub = cmd->commands[it]; + if (!sub) break; + if (sp_str_equal_cstr(name, sub->name)) { + sp_cli_eat(parser); + sp_cli_scope_t child = { .cmd = sub, .parent = &scope }; + return sp_cli_parse_cmd(parser, child); + } + } + return sp_cli_fail(cli, (sp_cli_err_t) { + .kind = SP_CLI_ERR_UNKNOWN_COMMAND, + .name = name, + }); + } + + if (!cmd->handler) { + parser->help = true; + return SP_OK; + } + } + + return SP_OK; +} + +SP_PRIVATE sp_str_t sp_cli_opt_label(c8* buf, u32 len, sp_cli_opt_t* opt) { + sp_io_mem_writer_t label = sp_zero; + sp_io_mem_writer_from_buffer(&label, buf, len); + + if (opt->brief) { + sp_fmt_io(&label.base, "-{}, ", sp_fmt_cstr(opt->brief)); + } + else { + sp_fmt_io(&label.base, " "); + } + sp_fmt_io(&label.base, "--{}", sp_fmt_cstr(opt->name)); + if (opt->placeholder) { + sp_fmt_io(&label.base, " {}", sp_fmt_cstr(opt->placeholder)); + } + + return sp_io_mem_writer_as_str(&label); +} + +SP_PRIVATE sp_str_t sp_cli_arg_label(c8* buf, u32 len, sp_cli_arg_t* arg) { + sp_io_mem_writer_t label = sp_zero; + sp_io_mem_writer_from_buffer(&label, buf, len); + + switch (arg->kind) { + case SP_CLI_ARG_REQUIRED: { + sp_fmt_io(&label.base, "<{}>", sp_fmt_cstr(arg->name)); + break; + } + case SP_CLI_ARG_OPTIONAL: { + sp_fmt_io(&label.base, "[{}]", sp_fmt_cstr(arg->name)); + break; + } + case SP_CLI_ARG_REST: { + sp_fmt_io(&label.base, "[{}...]", sp_fmt_cstr(arg->name)); + break; + } + } + + return sp_io_mem_writer_as_str(&label); +} + +sp_str_t sp_cli_arg_kind_to_str(sp_cli_arg_kind_t kind) { + switch (kind) { + SP_CLI_ARG_KIND(SP_X_NAMED_ENUM_CASE_TO_STRING) + } + SP_UNREACHABLE_RETURN(sp_str_lit("")); +} + +sp_str_t sp_cli_opt_kind_to_str(sp_cli_opt_kind_t kind) { + switch (kind) { + SP_CLI_OPT_KIND(SP_X_NAMED_ENUM_CASE_TO_STRING) + } + SP_UNREACHABLE_RETURN(sp_str_lit("")); +} + +sp_str_t sp_cli_result_to_str(sp_cli_result_t result) { + switch (result) { + SP_CLI_RESULT(SP_X_NAMED_ENUM_CASE_TO_STRING) + } + SP_UNREACHABLE_RETURN(sp_str_lit("")); +} + +sp_str_t sp_cli_err_kind_to_str(sp_cli_err_kind_t kind) { + switch (kind) { + SP_CLI_ERR_KIND(SP_X_NAMED_ENUM_CASE_TO_STRING) + } + SP_UNREACHABLE_RETURN(sp_str_lit("")); +} + +sp_cli_t sp_cli_parse(sp_cli_desc_t desc) { + sp_cli_t cli = sp_zero_s(sp_cli_t); + cli.user_data = desc.user_data; + + sp_cli_parser_t parser = sp_zero_s(sp_cli_parser_t); + parser.cli = &cli; + parser.args = desc.args; + parser.num_args = desc.num_args; + + sp_cli_scope_t scope = { .cmd = desc.root, .parent = SP_NULLPTR }; + if (sp_cli_parse_cmd(&parser, scope)) { + cli.status = SP_CLI_ERR; + } + else if (parser.help) { + cli.status = SP_CLI_HELP; + } + else { + cli.status = SP_CLI_OK; + } + return cli; +} + +sp_cli_result_t sp_cli_dispatch(sp_cli_t* cli) { + if (cli->status != SP_CLI_OK) { + return cli->status; + } + if (!cli->cmd || !cli->cmd->handler) { + cli->err = (sp_cli_err_t) { + .kind = SP_CLI_ERR_NO_HANDLER, + .name = cli->cmd ? sp_cli_str(cli->cmd->name) : sp_zero_s(sp_str_t), + }; + return SP_CLI_ERR; + } + return cli->cmd->handler(cli); +} + +void sp_cli_log_error(const c8* fmt, ...) { + va_list args; + va_start(args, fmt); + sp_cli_log_error_v(sp_cstr_as_str(fmt), args); + va_end(args); +} + +void sp_cli_log_error_v(sp_str_t fmt, va_list args) { + sp_io_stream_writer_t io = sp_io_get_std_err(); + sp_fmt_io(&io.base, "{.red}: ", sp_fmt_cstr("error")); + sp_fmt_io_v(&io.base, fmt, args); + sp_fmt_io(&io.base, "\n"); +} + +void sp_cli_err_write(sp_io_writer_t* io, sp_cli_err_t* err) { + switch (err->kind) { + case SP_CLI_ERR_NONE: { + break; + } + case SP_CLI_ERR_UNKNOWN_OPT: { + sp_fmt_io(io, "unknown option: --{}", sp_fmt_str(err->name)); + break; + } + case SP_CLI_ERR_UNKNOWN_BRIEF: { + sp_fmt_io(io, "unknown option: -{}", sp_fmt_str(err->name)); + break; + } + case SP_CLI_ERR_INVALID_VALUE: { + sp_fmt_io(io, "invalid value for option --{}: {.quote}", sp_fmt_str(err->name), sp_fmt_str(err->value)); + break; + } + case SP_CLI_ERR_MISSING_VALUE: { + sp_fmt_io(io, "missing value for option: --{}", sp_fmt_str(err->name)); + break; + } + case SP_CLI_ERR_MISSING_ARG: { + sp_fmt_io(io, "missing required argument: {}", sp_fmt_str(err->name)); + break; + } + case SP_CLI_ERR_UNEXPECTED_ARG: { + sp_fmt_io(io, "unexpected argument: {}", sp_fmt_str(err->value)); + break; + } + case SP_CLI_ERR_UNKNOWN_COMMAND: { + sp_fmt_io(io, "unknown command: {}", sp_fmt_str(err->name)); + break; + } + case SP_CLI_ERR_NO_HANDLER: { + sp_fmt_io(io, "no handler for command: {}", sp_fmt_str(err->name)); + break; + } + } +} + +SP_PRIVATE void sp_cli_error_print(sp_cli_t* cli) { + sp_io_stream_writer_t io = sp_io_get_std_err(); + sp_fmt_io(&io.base, "{.red}: ", sp_fmt_cstr("error")); + sp_cli_err_write(&io.base, &cli->err); + sp_fmt_io(&io.base, "\n"); +} + +sp_cli_result_t sp_cli_run(sp_cli_cmd_t* root, s32 num_args, const c8** args, void* user_data) { + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = root, + .args = num_args > 1 ? args + 1 : SP_NULLPTR, + .num_args = num_args > 1 ? sp_cast(u32, num_args - 1) : 0, + .user_data = user_data, + }); + + switch (cli.status) { + case SP_CLI_OK: break; + case SP_CLI_HELP: { + sp_io_stream_writer_t out = sp_io_get_std_out(); + sp_cli_usage_write(&out.base, cli.cmd); + return SP_CLI_HELP; + } + case SP_CLI_ERR: { + sp_cli_error_print(&cli); + sp_io_stream_writer_t err = sp_io_get_std_err(); + sp_cli_usage_write(&err.base, cli.cmd); + return SP_CLI_ERR; + } + case SP_CLI_CONTINUE: { + sp_unreachable_case(); + } + } + + // Handlers print their own errors; the library only prints errors it + // produced itself (e.g. dispatching a command with no handler). + sp_cli_result_t result = sp_cli_dispatch(&cli); + if (result == SP_CLI_ERR && cli.err.kind != SP_CLI_ERR_NONE) { + sp_cli_error_print(&cli); + } + return result; +} + +s32 sp_cli_main(sp_cli_cmd_t* root, s32 num_args, const c8** args, void* user_data) { + switch (sp_cli_run(root, num_args, args, user_data)) { + case SP_CLI_OK: return 0; + case SP_CLI_HELP: return 0; + case SP_CLI_CONTINUE: return 0; + case SP_CLI_ERR: return 1; + } + SP_UNREACHABLE_RETURN(1); +} + +void sp_cli_usage_write(sp_io_writer_t* io, sp_cli_cmd_t* cmd) { + c8 buf [SP_CLI_MAX_LABEL]; + + if (cmd->summary) { + sp_fmt_io(io, "{}\n\n", sp_fmt_cstr(cmd->summary)); + } + + sp_fmt_io(io, "{.green}\n {.cyan}", sp_fmt_cstr("usage"), sp_fmt_cstr(cmd->name)); + if (sp_cli_num_opts(cmd)) { + sp_fmt_io(io, " [options]"); + } + sp_carr_for(cmd->args, it) { + if (!cmd->args[it].name) break; + sp_fmt_io(io, " {}", sp_fmt_str(sp_cli_arg_label(buf, SP_CLI_MAX_LABEL, &cmd->args[it]))); + } + if (sp_cli_has_commands(cmd)) { + sp_fmt_io(io, " "); + } + sp_fmt_io(io, "\n"); + + if (sp_cli_num_opts(cmd)) { + u32 width = 0; + sp_carr_for(cmd->opts, it) { + if (!cmd->opts[it].name) break; + width = sp_max(width, sp_cli_opt_label(buf, SP_CLI_MAX_LABEL, &cmd->opts[it]).len); + } + sp_fmt_io(io, "\n{.green}\n", sp_fmt_cstr("options")); + sp_carr_for(cmd->opts, it) { + if (!cmd->opts[it].name) break; + sp_str_t label = sp_cli_opt_label(buf, SP_CLI_MAX_LABEL, &cmd->opts[it]); + sp_fmt_io(io, " {:<$ .yellow} {}\n", sp_fmt_uint(width), sp_fmt_str(label), sp_fmt_str(sp_cli_str(cmd->opts[it].summary))); + } + } + + if (cmd->args[0].name) { + u32 width = 0; + sp_carr_for(cmd->args, it) { + if (!cmd->args[it].name) break; + width = sp_max(width, sp_cli_arg_label(buf, SP_CLI_MAX_LABEL, &cmd->args[it]).len); + } + sp_fmt_io(io, "\n{.green}\n", sp_fmt_cstr("arguments")); + sp_carr_for(cmd->args, it) { + if (!cmd->args[it].name) break; + sp_str_t label = sp_cli_arg_label(buf, SP_CLI_MAX_LABEL, &cmd->args[it]); + sp_fmt_io(io, " {:<$ .yellow} {}\n", sp_fmt_uint(width), sp_fmt_str(label), sp_fmt_str(sp_cli_str(cmd->args[it].summary))); + } + } + + if (sp_cli_has_commands(cmd)) { + u32 width = 0; + sp_carr_for(cmd->commands, it) { + if (!cmd->commands[it]) break; + width = sp_max(width, sp_cli_str(cmd->commands[it]->name).len); + } + sp_fmt_io(io, "\n{.green}\n", sp_fmt_cstr("commands")); + sp_carr_for(cmd->commands, it) { + sp_cli_cmd_t* sub = cmd->commands[it]; + if (!sub) break; + sp_fmt_io(io, " {:<$ .yellow} {}\n", sp_fmt_uint(width), sp_fmt_str(sp_cli_str(sub->name)), sp_fmt_str(sp_cli_str(sub->summary))); + } + } +} + +#endif // SP_CLI_IMPLEMENTATION diff --git a/sp/sp_prompt.h b/sp/sp_prompt.h index 95c911a..ddfce79 100644 --- a/sp/sp_prompt.h +++ b/sp/sp_prompt.h @@ -1031,7 +1031,7 @@ void sp_prompt_ctx_init(sp_prompt_ctx_t* ctx, sp_mem_t mem, u32 cols, u32 rows) // // Empirically, you get pretty bad tearing on Windows without buffering. sp_io_stream_writer_t* fw = sp_mem_arena_alloc_type(ctx->arena, sp_io_stream_writer_t); - sp_io_get_std_out(fw); + *fw = sp_io_get_std_out(); ctx->writer = &fw->base; u64 buffer_size = ctx->cols * ctx->rows * SP_PROMPT_CELL_BUFFER_BYTES + SP_PROMPT_BUFFER_EXTRA_BYTES; diff --git a/test/amalg.c b/test/amalg.c index 0ec2273..c765e6f 100644 --- a/test/amalg.c +++ b/test/amalg.c @@ -4,6 +4,7 @@ #include "app.c" #include "asset.c" #include "array.c" +#include "cli.c" #include "etc.c" #include "cv.c" #include "env.c" diff --git a/test/cli.c b/test/cli.c new file mode 100644 index 0000000..1de878f --- /dev/null +++ b/test/cli.c @@ -0,0 +1,7 @@ +#define SP_CLI_IMPLEMENTATION +#include "cli/cli.h" +SP_TEST_MAIN() + +#include "cli/parse.c" +#include "cli/dispatch.c" +#include "cli/usage.c" diff --git a/test/cli/cli.h b/test/cli/cli.h new file mode 100644 index 0000000..1faa266 --- /dev/null +++ b/test/cli/cli.h @@ -0,0 +1,57 @@ +#ifndef CLI_TEST_H +#define CLI_TEST_H + +#include "sp.h" +#include "sp/sp_cli.h" +#include "test.h" +#include "utest.h" + +#define CLI_TEST_MAX_ARGS 8 +#define CLI_TEST_MAX_BINDS 4 + +typedef struct { + bool flags [CLI_TEST_MAX_BINDS]; + const c8* strs [CLI_TEST_MAX_BINDS]; + s64 nums [CLI_TEST_MAX_BINDS]; +} cli_binds_t; + +static cli_binds_t cli_binds; +static sp_str_t cli_dispatched; + +static sp_cli_result_t cli_handler_ok(sp_cli_t* cli) { + cli_dispatched = sp_str_view(cli->cmd->name); + return SP_CLI_OK; +} + +static sp_cli_result_t cli_handler_err(sp_cli_t* cli) { + cli_dispatched = sp_str_view(cli->cmd->name); + return SP_CLI_ERR; +} + +static u32 cli_count_args(const c8** args) { + u32 num = 0; + while (num < CLI_TEST_MAX_ARGS && args[num]) { + num++; + } + return num; +} + +#define CLI_TEST_FIXTURE(SUITE) \ + struct SUITE { \ + sp_mem_tracking_t tracker; \ + sp_mem_arena_t* arena; \ + struct { sp_mem_t tracking; sp_mem_t arena; } mem; \ + }; \ + UTEST_F_SETUP(SUITE) { \ + sp_mem_tracking_init(&ut.tracker); \ + ut.mem.tracking = sp_mem_tracking_as_allocator(&ut.tracker); \ + ut.arena = sp_mem_arena_new(ut.mem.tracking); \ + ut.mem.arena = sp_mem_arena_as_allocator(ut.arena); \ + } \ + UTEST_F_TEARDOWN(SUITE) { \ + sp_mem_arena_destroy(ut.arena); \ + EXPECT_TRUE(sp_mem_tracking_ok(&ut.tracker)); \ + sp_mem_tracking_deinit(&ut.tracker); \ + } + +#endif diff --git a/test/cli/dispatch.c b/test/cli/dispatch.c new file mode 100644 index 0000000..7331288 --- /dev/null +++ b/test/cli/dispatch.c @@ -0,0 +1,176 @@ +#include "cli.h" + +typedef struct { + sp_cli_result_t result; + const c8* dispatched; + sp_cli_err_kind_t err; + const c8* err_msg; +} cli_dispatch_expect_t; + +typedef struct { + const c8* args [CLI_TEST_MAX_ARGS]; + sp_cli_cmd_t cmd; + cli_dispatch_expect_t expect; +} cli_dispatch_test_t; + +CLI_TEST_FIXTURE(cli_dispatch) + +static void run_cli_dispatch_test(s32* utest_result, sp_mem_t mem, cli_dispatch_test_t t) { + cli_binds = sp_zero_s(cli_binds_t); + cli_dispatched = sp_zero_s(sp_str_t); + + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = &t.cmd, + .args = t.args, + .num_args = cli_count_args(t.args), + }); + sp_cli_result_t result = sp_cli_dispatch(&cli); + + EXPECT_EQ(t.expect.result, result); + SP_EXPECT_STR_EQ_CSTR(cli_dispatched, t.expect.dispatched ? t.expect.dispatched : ""); + EXPECT_EQ(t.expect.err, cli.err.kind); + if (t.expect.err_msg) { + sp_io_dyn_mem_writer_t io = sp_zero; + sp_io_dyn_mem_writer_init(mem, &io); + sp_cli_err_write(&io.base, &cli.err); + SP_EXPECT_STR_EQ_CSTR(sp_io_dyn_mem_writer_as_str(&io), t.expect.err_msg); + } +} + +UTEST_F(cli_dispatch, root_handler) { + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .cmd = { .name = "root", .handler = cli_handler_ok }, + .expect = { + .dispatched = "root", + }, + }); +} + +UTEST_F(cli_dispatch, command_handler) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .args = { "build" }, + .cmd = { .name = "root", .commands = { &build } }, + .expect = { + .dispatched = "build", + }, + }); +} + +UTEST_F(cli_dispatch, handler_result_propagates) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_err }; + + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .args = { "build" }, + .cmd = { .name = "root", .commands = { &build } }, + .expect = { + .result = SP_CLI_ERR, + .dispatched = "build", + }, + }); +} + +UTEST_F(cli_dispatch, nested_command_handler) { + sp_cli_cmd_t install = { .name = "install", .handler = cli_handler_ok }; + sp_cli_cmd_t tool = { .name = "tool", .commands = { &install } }; + + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .args = { "tool", "install" }, + .cmd = { .name = "root", .commands = { &tool } }, + .expect = { + .dispatched = "install", + }, + }); +} + +UTEST_F(cli_dispatch, no_handler) { + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .cmd = { .name = "root" }, + .expect = { + .result = SP_CLI_ERR, + .err = SP_CLI_ERR_NO_HANDLER, + .err_msg = "no handler for command: root", + }, + }); +} + +UTEST_F(cli_dispatch, parse_error_skips_dispatch) { + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .args = { "--bogus" }, + .cmd = { .name = "root", .handler = cli_handler_ok }, + .expect = { + .result = SP_CLI_ERR, + .err = SP_CLI_ERR_UNKNOWN_OPT, + .err_msg = "unknown option: --bogus", + }, + }); +} + +UTEST_F(cli_dispatch, missing_command_skips_dispatch) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .cmd = { .name = "root", .commands = { &build } }, + .expect = { + .result = SP_CLI_HELP, + }, + }); +} + +UTEST_F(cli_dispatch, help_skips_dispatch) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_err }; + + run_cli_dispatch_test(&ur, ut.mem.arena, (cli_dispatch_test_t) { + .args = { "build", "--help" }, + .cmd = { .name = "root", .commands = { &build } }, + .expect = { + .result = SP_CLI_HELP, + }, + }); +} + +static sp_cli_result_t cli_handler_user_data(sp_cli_t* cli) { + *sp_cast(u32*, cli->user_data) = 69; + return SP_CLI_OK; +} + +static sp_cli_result_t cli_handler_continue(sp_cli_t* cli) { + cli_dispatched = sp_str_view(cli->cmd->name); + return SP_CLI_CONTINUE; +} + +UTEST_F(cli_dispatch, run_user_data) { + u32 value = 0; + sp_cli_cmd_t cmd = { .name = "root", .handler = cli_handler_user_data }; + const c8* args [] = { "root" }; + + EXPECT_EQ(SP_CLI_OK, sp_cli_run(&cmd, sp_carr_len(args), args, &value)); + EXPECT_EQ(69u, value); +} + +UTEST_F(cli_dispatch, run_skips_program_name) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + sp_cli_cmd_t root = { .name = "root", .commands = { &build } }; + const c8* args [] = { "root", "build" }; + + cli_dispatched = sp_zero_s(sp_str_t); + EXPECT_EQ(SP_CLI_OK, sp_cli_run(&root, sp_carr_len(args), args, SP_NULLPTR)); + SP_EXPECT_STR_EQ_CSTR(cli_dispatched, "build"); +} + +UTEST_F(cli_dispatch, run_handler_continue_propagates) { + sp_cli_cmd_t cmd = { .name = "root", .handler = cli_handler_continue }; + const c8* args [] = { "root" }; + + EXPECT_EQ(SP_CLI_CONTINUE, sp_cli_run(&cmd, sp_carr_len(args), args, SP_NULLPTR)); +} + +UTEST_F(cli_dispatch, main_exit_codes) { + sp_cli_cmd_t ok = { .name = "root", .handler = cli_handler_ok }; + sp_cli_cmd_t err = { .name = "root", .handler = cli_handler_err }; + const c8* args [] = { "root" }; + + EXPECT_EQ(0, sp_cli_main(&ok, sp_carr_len(args), args, SP_NULLPTR)); + EXPECT_EQ(1, sp_cli_main(&err, sp_carr_len(args), args, SP_NULLPTR)); +} diff --git a/test/cli/parse.c b/test/cli/parse.c new file mode 100644 index 0000000..4e1be5c --- /dev/null +++ b/test/cli/parse.c @@ -0,0 +1,988 @@ +#include "cli.h" + +typedef struct { + sp_cli_err_kind_t err; + const c8* err_name; + const c8* err_value; + const c8* err_msg; + const c8* cmd; + bool help; + bool flags [CLI_TEST_MAX_BINDS]; + const c8* strs [CLI_TEST_MAX_BINDS]; + s64 nums [CLI_TEST_MAX_BINDS]; + const c8* rest [CLI_TEST_MAX_ARGS]; +} cli_parse_expect_t; + +typedef struct { + const c8* args [CLI_TEST_MAX_ARGS]; + cli_binds_t binds; + sp_cli_cmd_t cmd; + cli_parse_expect_t expect; +} cli_parse_test_t; + +CLI_TEST_FIXTURE(cli_parse) + +static void run_cli_parse_test(s32* utest_result, sp_mem_t mem, cli_parse_test_t t) { + cli_binds = t.binds; + + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = &t.cmd, + .args = t.args, + .num_args = cli_count_args(t.args), + }); + + if (t.expect.err) { + EXPECT_EQ(SP_CLI_ERR, cli.status); + EXPECT_EQ(t.expect.err, cli.err.kind); + if (t.expect.err_name) { + SP_EXPECT_STR_EQ_CSTR(cli.err.name, t.expect.err_name); + } + if (t.expect.err_value) { + SP_EXPECT_STR_EQ_CSTR(cli.err.value, t.expect.err_value); + } + if (t.expect.err_msg) { + sp_io_dyn_mem_writer_t io = sp_zero; + sp_io_dyn_mem_writer_init(mem, &io); + sp_cli_err_write(&io.base, &cli.err); + SP_EXPECT_STR_EQ_CSTR(sp_io_dyn_mem_writer_as_str(&io), t.expect.err_msg); + } + } + else if (t.expect.help) { + EXPECT_EQ(SP_CLI_HELP, cli.status); + EXPECT_EQ(SP_CLI_ERR_NONE, cli.err.kind); + } + else { + EXPECT_EQ(SP_CLI_OK, cli.status); + EXPECT_EQ(SP_CLI_ERR_NONE, cli.err.kind); + } + + if (t.expect.cmd) { + EXPECT_NE(SP_NULLPTR, (void*)cli.cmd); + if (cli.cmd) { + SP_EXPECT_STR_EQ_CSTR(sp_str_view(cli.cmd->name), t.expect.cmd); + } + } + + sp_carr_for(t.expect.flags, it) { + EXPECT_EQ(t.expect.flags[it], cli_binds.flags[it]); + } + sp_carr_for(t.expect.strs, it) { + SP_EXPECT_STR_EQ_CSTR(sp_cstr_as_str(cli_binds.strs[it]), t.expect.strs[it] ? t.expect.strs[it] : ""); + } + sp_carr_for(t.expect.nums, it) { + EXPECT_EQ(t.expect.nums[it], cli_binds.nums[it]); + } + + EXPECT_EQ(cli_count_args(t.expect.rest), cli.num_rest); + sp_carr_for(t.expect.rest, it) { + if (!t.expect.rest[it]) break; + if (it >= cli.num_rest) break; + SP_EXPECT_STR_EQ_CSTR(sp_cstr_as_str(cli.rest[it]), t.expect.rest[it]); + } +} + +UTEST_F(cli_parse, empty_args) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .cmd = { .name = "test" }, + .expect = { .cmd = "test" }, + }); +} + +UTEST_F(cli_parse, required_arg_present) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "foo" }, + .cmd = { + .name = "test", + .args = { + { .name = "path", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "foo" }, + }, + }); +} + +UTEST_F(cli_parse, required_arg_missing) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .cmd = { + .name = "test", + .args = { + { .name = "path", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_MISSING_ARG, + .err_name = "path", + .err_msg = "missing required argument: path", + }, + }); +} + +UTEST_F(cli_parse, optional_arg_present) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "bar" }, + .cmd = { + .name = "test", + .args = { + { .name = "path", .kind = SP_CLI_ARG_OPTIONAL, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "bar" }, + }, + }); +} + +UTEST_F(cli_parse, optional_arg_missing) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .cmd = { + .name = "test", + .args = { + { .name = "path", .kind = SP_CLI_ARG_OPTIONAL, .ptr = &cli_binds.strs[0] }, + }, + }, + }); +} + +UTEST_F(cli_parse, multiple_positionals) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "first", "second" }, + .cmd = { + .name = "test", + .args = { + { .name = "one", .ptr = &cli_binds.strs[0] }, + { .name = "two", .ptr = &cli_binds.strs[1] }, + }, + }, + .expect = { + .strs = { "first", "second" }, + }, + }); +} + +UTEST_F(cli_parse, unexpected_positional) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "a", "b" }, + .cmd = { + .name = "test", + .args = { + { .name = "one", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_UNEXPECTED_ARG, + .err_value = "b", + .err_msg = "unexpected argument: b", + }, + }); +} + +UTEST_F(cli_parse, dash_is_positional) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-" }, + .cmd = { + .name = "test", + .args = { + { .name = "path", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "-" }, + }, + }); +} + +UTEST_F(cli_parse, bool_opt_brief) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-v" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .flags = { true }, + }, + }); +} + +UTEST_F(cli_parse, bool_opt_long) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .flags = { true }, + }, + }); +} + +UTEST_F(cli_parse, bool_opt_cluster) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-vf" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + { .brief = "f", .name = "force", .ptr = &cli_binds.flags[1] }, + }, + }, + .expect = { + .flags = { true, true }, + }, + }); +} + +UTEST_F(cli_parse, cluster_unknown_brief) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-vx" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_UNKNOWN_BRIEF, + .err_name = "x", + .err_msg = "unknown option: -x", + .flags = { true }, + }, + }); +} + +UTEST_F(cli_parse, string_opt_brief) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-o", "foo" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "foo" }, + }, + }); +} + +UTEST_F(cli_parse, string_opt_brief_attached) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-ofoo" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "foo" }, + }, + }); +} + +UTEST_F(cli_parse, string_opt_long_eq) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--output=bar" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "bar" }, + }, + }); +} + +UTEST_F(cli_parse, string_opt_long_space) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--output", "baz" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "baz" }, + }, + }); +} + +UTEST_F(cli_parse, string_opt_missing_value_at_end) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--output" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_MISSING_VALUE, + .err_name = "output", + .err_msg = "missing value for option: --output", + }, + }); +} + +UTEST_F(cli_parse, string_opt_missing_value_before_opt) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--output", "--verbose" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_MISSING_VALUE, + .err_name = "output", + .err_msg = "missing value for option: --output", + }, + }); +} + +UTEST_F(cli_parse, int_opt) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--jobs", "4" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "j", .name = "jobs", .kind = SP_CLI_OPT_INTEGER, .ptr = &cli_binds.nums[0] }, + }, + }, + .expect = { + .nums = { 4 }, + }, + }); +} + +UTEST_F(cli_parse, int_opt_negative_eq) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--jobs=-4" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "j", .name = "jobs", .kind = SP_CLI_OPT_INTEGER, .ptr = &cli_binds.nums[0] }, + }, + }, + .expect = { + .nums = { -4 }, + }, + }); +} + +UTEST_F(cli_parse, int_opt_invalid) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--jobs", "abc" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "j", .name = "jobs", .kind = SP_CLI_OPT_INTEGER, .ptr = &cli_binds.nums[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_INVALID_VALUE, + .err_name = "jobs", + .err_value = "abc", + .err_msg = "invalid value for option --jobs: \"abc\"", + }, + }); +} + +UTEST_F(cli_parse, unknown_long_opt) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--bogus" }, + .cmd = { .name = "test" }, + .expect = { + .err = SP_CLI_ERR_UNKNOWN_OPT, + .err_name = "bogus", + .err_msg = "unknown option: --bogus", + }, + }); +} + +UTEST_F(cli_parse, brief_after_long_only_opt) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-m", "debug" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "f", .name = "force", .ptr = &cli_binds.flags[0] }, + { .name = "bin", .ptr = &cli_binds.flags[1] }, + { .brief = "m", .name = "mode", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "debug" }, + }, + }); +} + +UTEST_F(cli_parse, opt_after_positional) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "input.c", "--verbose" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .args = { + { .name = "file", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .flags = { true }, + .strs = { "input.c" }, + }, + }); +} + +UTEST_F(cli_parse, mixed_opts_and_args) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose", "input.c", "--output", "out.o" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[1] }, + }, + .args = { + { .name = "file", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .flags = { true }, + .strs = { "input.c", "out.o" }, + }, + }); +} + +UTEST_F(cli_parse, command_resolved) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "build" }, + .cmd = { .name = "root", .commands = { &build } }, + .expect = { .cmd = "build" }, + }); +} + +UTEST_F(cli_parse, command_unknown) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "bogus" }, + .cmd = { .name = "root", .commands = { &build } }, + .expect = { + .err = SP_CLI_ERR_UNKNOWN_COMMAND, + .err_name = "bogus", + .err_msg = "unknown command: bogus", + }, + }); +} + +UTEST_F(cli_parse, command_missing_is_help) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .cmd = { .name = "root", .commands = { &build } }, + .expect = { + .cmd = "root", + .help = true, + }, + }); +} + +UTEST_F(cli_parse, command_missing_with_root_handler) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .cmd = { .name = "root", .commands = { &build }, .handler = cli_handler_ok }, + .expect = { .cmd = "root" }, + }); +} + +UTEST_F(cli_parse, opts_at_each_level) { + sp_cli_cmd_t install = { + .name = "install", + .opts = { + { .brief = "f", .name = "force", .ptr = &cli_binds.flags[1] }, + }, + .handler = cli_handler_ok, + }; + sp_cli_cmd_t tool = { .name = "tool", .commands = { &install } }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose", "tool", "install", "--force" }, + .cmd = { + .name = "root", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .commands = { &tool }, + }, + .expect = { + .cmd = "install", + .flags = { true, true }, + }, + }); +} + +UTEST_F(cli_parse, command_positionals) { + sp_cli_cmd_t install = { + .name = "install", + .args = { + { .name = "package", .ptr = &cli_binds.strs[0] }, + { .name = "version", .kind = SP_CLI_ARG_OPTIONAL, .ptr = &cli_binds.strs[1] }, + }, + .handler = cli_handler_ok, + }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "install", "sp", "1.0.0" }, + .cmd = { .name = "root", .commands = { &install } }, + .expect = { + .cmd = "install", + .strs = { "sp", "1.0.0" }, + }, + }); +} + +UTEST_F(cli_parse, error_reports_deepest_command) { + sp_cli_cmd_t install = { + .name = "install", + .args = { + { .name = "package", .ptr = &cli_binds.strs[0] }, + }, + .handler = cli_handler_ok, + }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "install" }, + .cmd = { .name = "root", .commands = { &install } }, + .expect = { + .err = SP_CLI_ERR_MISSING_ARG, + .err_name = "package", + .err_msg = "missing required argument: package", + .cmd = "install", + }, + }); +} + +UTEST_F(cli_parse, parent_opt_after_command) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "build", "--verbose" }, + .cmd = { + .name = "root", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .commands = { &build }, + }, + .expect = { + .cmd = "build", + .flags = { true }, + }, + }); +} + +UTEST_F(cli_parse, parent_brief_after_command) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "build", "-v" }, + .cmd = { + .name = "root", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .commands = { &build }, + }, + .expect = { + .cmd = "build", + .flags = { true }, + }, + }); +} + +UTEST_F(cli_parse, child_opt_shadows_parent) { + sp_cli_cmd_t build = { + .name = "build", + .opts = { + { .brief = "m", .name = "mode", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[1] }, + }, + .handler = cli_handler_ok, + }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "build", "--mode", "debug" }, + .cmd = { + .name = "root", + .opts = { + { .brief = "m", .name = "mode", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + .commands = { &build }, + }, + .expect = { + .cmd = "build", + .strs = { "", "debug" }, + }, + }); +} + +UTEST_F(cli_parse, unknown_opt_misses_all_scopes) { + sp_cli_cmd_t build = { .name = "build", .handler = cli_handler_ok }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "build", "--bogus" }, + .cmd = { + .name = "root", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .commands = { &build }, + }, + .expect = { + .err = SP_CLI_ERR_UNKNOWN_OPT, + .err_name = "bogus", + .err_msg = "unknown option: --bogus", + }, + }); +} + +UTEST_F(cli_parse, bool_opt_explicit_true) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose=true" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .flags = { true }, + }, + }); +} + +UTEST_F(cli_parse, bool_opt_explicit_false) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose=false" }, + .binds = { + .flags = { true }, + }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + }); +} + +UTEST_F(cli_parse, bool_opt_invalid_value) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose=banana" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .err = SP_CLI_ERR_INVALID_VALUE, + .err_name = "verbose", + .err_value = "banana", + .err_msg = "invalid value for option --verbose: \"banana\"", + }, + }); +} + +UTEST_F(cli_parse, bool_opt_does_not_consume_token) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose", "true" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .args = { + { .name = "path", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .flags = { true }, + .strs = { "true" }, + }, + }); +} + +UTEST_F(cli_parse, double_dash_ends_options) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--", "--verbose" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .args = { + { .name = "path", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "--verbose" }, + }, + }); +} + +UTEST_F(cli_parse, rest_captures_trailing) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "script", "foo", "--bar" }, + .cmd = { + .name = "run", + .args = { + { .name = "entry", .ptr = &cli_binds.strs[0] }, + { .name = "args", .kind = SP_CLI_ARG_REST }, + }, + }, + .expect = { + .strs = { "script" }, + .rest = { "foo", "--bar" }, + }, + }); +} + +UTEST_F(cli_parse, rest_empty) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "script" }, + .cmd = { + .name = "run", + .args = { + { .name = "entry", .ptr = &cli_binds.strs[0] }, + { .name = "args", .kind = SP_CLI_ARG_REST }, + }, + }, + .expect = { + .strs = { "script" }, + }, + }); +} + +UTEST_F(cli_parse, rest_after_double_dash) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "script", "--", "--verbose" }, + .cmd = { + .name = "run", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .args = { + { .name = "entry", .ptr = &cli_binds.strs[0] }, + { .name = "args", .kind = SP_CLI_ARG_REST }, + }, + }, + .expect = { + .strs = { "script" }, + .rest = { "--verbose" }, + }, + }); +} + +UTEST_F(cli_parse, rest_stops_option_parsing) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "script", "x", "--verbose" }, + .cmd = { + .name = "run", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .args = { + { .name = "entry", .ptr = &cli_binds.strs[0] }, + { .name = "args", .kind = SP_CLI_ARG_REST }, + }, + }, + .expect = { + .strs = { "script" }, + .rest = { "x", "--verbose" }, + }, + }); +} + +UTEST_F(cli_parse, opts_before_rest_parse) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--verbose", "script", "a" }, + .cmd = { + .name = "run", + .opts = { + { .brief = "v", .name = "verbose", .ptr = &cli_binds.flags[0] }, + }, + .args = { + { .name = "entry", .ptr = &cli_binds.strs[0] }, + { .name = "args", .kind = SP_CLI_ARG_REST }, + }, + }, + .expect = { + .flags = { true }, + .strs = { "script" }, + .rest = { "a" }, + }, + }); +} + +UTEST_F(cli_parse, help_long) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--help" }, + .cmd = { .name = "test" }, + .expect = { + .cmd = "test", + .help = true, + }, + }); +} + +UTEST_F(cli_parse, help_brief) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-h" }, + .cmd = { .name = "test" }, + .expect = { + .cmd = "test", + .help = true, + }, + }); +} + +UTEST_F(cli_parse, help_skips_validation) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "-h" }, + .cmd = { + .name = "test", + .args = { + { .name = "path", .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .help = true, + }, + }); +} + +UTEST_F(cli_parse, help_reports_deepest_command) { + sp_cli_cmd_t install = { + .name = "install", + .args = { + { .name = "package", .ptr = &cli_binds.strs[0] }, + }, + .handler = cli_handler_ok, + }; + + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "install", "--help" }, + .cmd = { .name = "root", .commands = { &install } }, + .expect = { + .cmd = "install", + .help = true, + }, + }); +} + +UTEST_F(cli_parse, binds_views_into_args) { + const c8* package = "sp"; + const c8* mode = "debug"; + const c8* args [] = { "--mode", mode, package, "x" }; + sp_cli_cmd_t cmd = { + .name = "run", + .opts = { + { .name = "mode", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[1] }, + }, + .args = { + { .name = "entry", .ptr = &cli_binds.strs[0] }, + { .name = "args", .kind = SP_CLI_ARG_REST }, + }, + }; + + cli_binds = sp_zero_s(cli_binds_t); + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = &cmd, + .args = args, + .num_args = sp_carr_len(args), + }); + + EXPECT_EQ(SP_CLI_OK, cli.status); + EXPECT_EQ(1u, cli.num_rest); + + EXPECT_EQ(package, cli_binds.strs[0]); + EXPECT_EQ(mode, cli_binds.strs[1]); + EXPECT_EQ(args + 3, cli.rest); +} + +UTEST_F(cli_parse, attached_values_are_argv_tails) { + const c8* eq = "--mode=release"; + const c8* cluster = "-odist"; + const c8* args [] = { eq, cluster }; + sp_cli_cmd_t cmd = { + .name = "run", + .opts = { + { .name = "mode", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + { .brief = "o", .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[1] }, + }, + }; + + cli_binds = sp_zero_s(cli_binds_t); + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = &cmd, + .args = args, + .num_args = sp_carr_len(args), + }); + + EXPECT_EQ(SP_CLI_OK, cli.status); + EXPECT_EQ(eq + 7, cli_binds.strs[0]); + EXPECT_EQ(cluster + 2, cli_binds.strs[1]); +} + +UTEST_F(cli_parse, string_opt_empty_value) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--output=" }, + .binds = { + .strs = { "unset" }, + }, + .cmd = { + .name = "test", + .opts = { + { .name = "output", .kind = SP_CLI_OPT_STRING, .ptr = &cli_binds.strs[0] }, + }, + }, + .expect = { + .strs = { "" }, + }, + }); + EXPECT_NE(SP_NULLPTR, (void*)cli_binds.strs[0]); +} + +UTEST_F(cli_parse, declared_help_opt_wins) { + run_cli_parse_test(&ur, ut.mem.arena, (cli_parse_test_t) { + .args = { "--help" }, + .cmd = { + .name = "test", + .opts = { + { .brief = "h", .name = "help", .ptr = &cli_binds.flags[0] }, + }, + }, + .expect = { + .cmd = "test", + .flags = { true }, + }, + }); +} diff --git a/test/cli/usage.c b/test/cli/usage.c new file mode 100644 index 0000000..9d3b3f3 --- /dev/null +++ b/test/cli/usage.c @@ -0,0 +1,135 @@ +#include "cli.h" + +#define CLI_TEST_MAX_NEEDLES 16 + +typedef struct { + const c8* contains [CLI_TEST_MAX_NEEDLES]; + const c8* excludes [CLI_TEST_MAX_NEEDLES]; +} cli_usage_expect_t; + +typedef struct { + sp_cli_cmd_t cmd; + cli_usage_expect_t expect; +} cli_usage_test_t; + +CLI_TEST_FIXTURE(cli_usage) + +static void run_cli_usage_test(s32* utest_result, sp_mem_t mem, cli_usage_test_t t) { + sp_io_dyn_mem_writer_t io = sp_zero; + sp_io_dyn_mem_writer_init(mem, &io); + sp_cli_usage_write(&io.base, &t.cmd); + sp_str_t usage = sp_io_dyn_mem_writer_as_str(&io); + + sp_carr_for(t.expect.contains, it) { + if (!t.expect.contains[it]) break; + bool found = sp_str_contains(usage, sp_str_view(t.expect.contains[it])); + EXPECT_TRUE(found); + if (!found) { + SP_TEST_REPORT("missing {.quote} in:\n{}", sp_fmt_cstr(t.expect.contains[it]), sp_fmt_str(usage)); + } + } + sp_carr_for(t.expect.excludes, it) { + if (!t.expect.excludes[it]) break; + bool found = sp_str_contains(usage, sp_str_view(t.expect.excludes[it])); + EXPECT_FALSE(found); + if (found) { + SP_TEST_REPORT("unexpected {.quote} in:\n{}", sp_fmt_cstr(t.expect.excludes[it]), sp_fmt_str(usage)); + } + } +} + +UTEST_F(cli_usage, minimal) { + run_cli_usage_test(&ur, ut.mem.arena, (cli_usage_test_t) { + .cmd = { .name = "test" }, + .expect = { + .contains = { "usage", "test" }, + .excludes = { "[options]", "", "options", "arguments", "commands" }, + }, + }); +} + +UTEST_F(cli_usage, opts_and_args) { + run_cli_usage_test(&ur, ut.mem.arena, (cli_usage_test_t) { + .cmd = { + .name = "install", + .summary = "Install a package", + .opts = { + { .brief = "f", .name = "force", .summary = "Force reinstall" }, + { .name = "version", .kind = SP_CLI_OPT_STRING, .summary = "Version to install", .placeholder = "VERSION" }, + }, + .args = { + { .name = "package", .summary = "The package to install" }, + { .name = "target", .kind = SP_CLI_ARG_OPTIONAL, .summary = "Install target" }, + { .name = "extra", .kind = SP_CLI_ARG_REST, .summary = "Passed through verbatim" }, + }, + }, + .expect = { + .contains = { + "Install a package", + "usage", + "install", + " [options] [target] [extra...]", + "options", + "-f, --force", + "--version VERSION", + "Force reinstall", + "Version to install", + "arguments", + "", + "[target]", + "[extra...]", + "The package to install", + "Passed through verbatim", + }, + .excludes = { "", "commands" }, + }, + }); +} + +UTEST_F(cli_usage, commands) { + sp_cli_cmd_t add = { .name = "add", .summary = "Add a package", .handler = cli_handler_ok }; + sp_cli_cmd_t build = { .name = "build", .summary = "Build the project", .handler = cli_handler_ok }; + + run_cli_usage_test(&ur, ut.mem.arena, (cli_usage_test_t) { + .cmd = { + .name = "pkg", + .summary = "A package manager", + .commands = { &add, &build }, + }, + .expect = { + .contains = { + "A package manager", + "pkg", + " ", + "commands", + "add", + "Add a package", + "build", + "Build the project", + }, + .excludes = { "[options]", "arguments" }, + }, + }); +} + +UTEST_F(cli_usage, parsed_command_usage) { + sp_cli_cmd_t install = { .name = "install", .handler = cli_handler_ok }; + sp_cli_cmd_t tool = { .name = "tool", .commands = { &install } }; + sp_cli_cmd_t root = { .name = "pkg", .commands = { &tool } }; + const c8* args [] = { "tool", "install" }; + + sp_cli_t cli = sp_cli_parse((sp_cli_desc_t) { + .root = &root, + .args = args, + .num_args = sp_carr_len(args), + }); + + sp_io_dyn_mem_writer_t io = sp_zero; + sp_io_dyn_mem_writer_init(ut.mem.arena, &io); + sp_cli_usage_write(&io.base, cli.cmd); + sp_str_t usage = sp_io_dyn_mem_writer_as_str(&io); + + EXPECT_EQ(SP_CLI_OK, cli.status); + EXPECT_TRUE(sp_str_contains(usage, sp_str_lit("install"))); + EXPECT_FALSE(sp_str_contains(usage, sp_str_lit("pkg"))); +} diff --git a/tools/windows/sp/examples/cli.vcxproj b/tools/windows/sp/examples/cli.vcxproj new file mode 100644 index 0000000..9540b4c --- /dev/null +++ b/tools/windows/sp/examples/cli.vcxproj @@ -0,0 +1,62 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {10000014-0014-4014-8014-000000000014} + cli + cli + 10.0 + cli + ..\..\..\..\example\cli.c + ..\..\..\..\sp.h + false + ..\..\..\..;..\..\..\..\sp + + + + Debug + x64 + + + Application + true + v143 + MultiByte + + + Application + false + v143 + true + MultiByte + + + + + + + + + + + + + + $(SolutionDir)build\vs\$(Configuration)\bin\examples\ + $(SolutionDir)build\vs\$(Configuration)\obj\examples\$(MSBuildProjectName)\ + + + + + diff --git a/tools/windows/sp/sp.sln b/tools/windows/sp/sp.sln index 16c6a9b..7d84aa7 100644 --- a/tools/windows/sp/sp.sln +++ b/tools/windows/sp/sp.sln @@ -76,6 +76,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "io", "examples\io.vcxproj", EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "prompt_fancy", "examples\prompt_fancy.vcxproj", "{10000013-0013-4013-8013-000000000013}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "cli", "examples\cli.vcxproj", "{10000014-0014-4014-8014-000000000014}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -202,6 +204,10 @@ Global {10000013-0013-4013-8013-000000000013}.Debug|x64.Build.0 = Debug|x64 {10000013-0013-4013-8013-000000000013}.Release|x64.ActiveCfg = Release|x64 {10000013-0013-4013-8013-000000000013}.Release|x64.Build.0 = Release|x64 + {10000014-0014-4014-8014-000000000014}.Debug|x64.ActiveCfg = Debug|x64 + {10000014-0014-4014-8014-000000000014}.Debug|x64.Build.0 = Debug|x64 + {10000014-0014-4014-8014-000000000014}.Release|x64.ActiveCfg = Release|x64 + {10000014-0014-4014-8014-000000000014}.Release|x64.Build.0 = Release|x64 {21A5F3D1-2C70-4A11-9F8B-1A2B3C4D5E61}.Debug|x64.ActiveCfg = Debug|x64 {21A5F3D1-2C70-4A11-9F8B-1A2B3C4D5E61}.Debug|x64.Build.0 = Debug|x64 {21A5F3D1-2C70-4A11-9F8B-1A2B3C4D5E61}.Release|x64.ActiveCfg = Release|x64 @@ -244,5 +250,6 @@ Global {10000011-0011-4011-8011-000000000011} = {FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB} {10000012-0012-4012-8012-000000000012} = {FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB} {10000013-0013-4013-8013-000000000013} = {FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB} + {10000014-0014-4014-8014-000000000014} = {FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB} EndGlobalSection EndGlobal