aboutsummaryrefslogtreecommitdiffstats
path: root/nihil.cli
diff options
context:
space:
mode:
Diffstat (limited to 'nihil.cli')
-rw-r--r--nihil.cli/CMakeLists.txt14
-rw-r--r--nihil.cli/command.cc51
-rw-r--r--nihil.cli/command.ccm48
-rw-r--r--nihil.cli/command_map.cc267
-rw-r--r--nihil.cli/command_map.ccm67
-rw-r--r--nihil.cli/command_node.cc42
-rw-r--r--nihil.cli/command_node.ccm35
-rw-r--r--nihil.cli/command_path.ccm16
-rw-r--r--nihil.cli/command_tree.cc187
-rw-r--r--nihil.cli/command_tree.ccm107
-rw-r--r--nihil.cli/dispatch_command.cc82
-rw-r--r--nihil.cli/dispatch_command.ccm31
-rw-r--r--nihil.cli/nihil.cli.ccm7
-rw-r--r--nihil.cli/registry.cc57
-rw-r--r--nihil.cli/registry.ccm28
-rw-r--r--nihil.cli/test.cc100
-rw-r--r--nihil.cli/test_command_map.cc32
-rw-r--r--nihil.cli/usage_error.ccm2
18 files changed, 801 insertions, 372 deletions
diff --git a/nihil.cli/CMakeLists.txt b/nihil.cli/CMakeLists.txt
index a677798..b05ed3a 100644
--- a/nihil.cli/CMakeLists.txt
+++ b/nihil.cli/CMakeLists.txt
@@ -6,18 +6,26 @@ target_sources(nihil.cli
PUBLIC FILE_SET modules TYPE CXX_MODULES FILES
nihil.cli.ccm
- command_map.ccm
+ command.ccm
+ command_tree.ccm
+ command_node.ccm
+ dispatch_command.ccm
+ registry.ccm
usage_error.ccm
PRIVATE
- command_map.cc
+ command.cc
+ command_tree.cc
+ command_node.cc
+ dispatch_command.cc
+ registry.cc
)
if(NIHIL_TESTS)
enable_testing()
add_executable(nihil.cli.test
- test_command_map.cc
+ test.cc
)
target_link_libraries(nihil.cli.test PRIVATE
nihil.cli
diff --git a/nihil.cli/command.cc b/nihil.cli/command.cc
new file mode 100644
index 0000000..475cad0
--- /dev/null
+++ b/nihil.cli/command.cc
@@ -0,0 +1,51 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <expected>
+#include <functional>
+#include <iostream>
+#include <print>
+#include <string>
+
+// For EX_USAGE. While <sysexits.h> is deprecated, there's no other standard
+// exit code for 'usage error'; some programs use 2 (common on Linux), but
+// 2 is also used for many other exit codes.
+#include <sysexits.h>
+
+module nihil.cli;
+
+import nihil.error;
+import :registry;
+
+namespace nihil {
+
+command::command(std::string_view path, std::string_view usage,
+ command_function_t handler)
+ : command_node(path)
+ , m_usage(usage)
+ , m_handler(std::move(handler))
+{
+ register_command(this);
+}
+
+auto command::usage(this command const &self) noexcept -> std::string_view
+{
+ return self.m_usage;
+}
+
+auto command::invoke(int argc, char **argv) const
+ -> std::expected<int, error>
+{
+ try {
+ return std::invoke(m_handler, argc, argv);
+ } catch (usage_error const &err) {
+ std::print(std::cerr, "{}\n", err.what());
+ std::print(std::cerr, "usage: {} {}", path(), usage());
+ return EX_USAGE;
+ }
+}
+
+} // namespace nihil
diff --git a/nihil.cli/command.ccm b/nihil.cli/command.ccm
new file mode 100644
index 0000000..74ef030
--- /dev/null
+++ b/nihil.cli/command.ccm
@@ -0,0 +1,48 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <expected>
+#include <functional>
+#include <string>
+
+export module nihil.cli:command;
+
+import nihil.error;
+import :command_node;
+
+namespace nihil {
+
+export struct command;
+
+/*
+ * A command that can be invoked. Instantiating a command adds this command
+ * to the global command table. If an error occurs, the program will abort.
+ */
+
+using command_handler_t = int (int, char **);
+using command_function_t = std::function<command_handler_t>;
+
+export struct command final : command_node {
+ command(std::string_view path, std::string_view usage,
+ command_function_t);
+
+ command(std::string_view path, std::string_view usage, auto &&fn)
+ : command(path, usage, command_function_t(fn))
+ {}
+
+ [[nodiscard]] auto usage(this command const &) noexcept
+ -> std::string_view;
+
+ [[nodiscard]] auto invoke(int argc, char **argv) const
+ -> std::expected<int, error> override;
+
+private:
+ std::string_view m_path;
+ std::string_view m_usage;
+ command_function_t m_handler;
+};
+
+} // namespace nihil
diff --git a/nihil.cli/command_map.cc b/nihil.cli/command_map.cc
deleted file mode 100644
index c656c62..0000000
--- a/nihil.cli/command_map.cc
+++ /dev/null
@@ -1,267 +0,0 @@
-/*
- * This source code is released into the public domain.
- */
-
-module;
-
-#include <cstdio>
-#include <functional>
-#include <iostream>
-#include <map>
-#include <ranges>
-#include <string>
-#include <utility>
-
-#include <unistd.h>
-
-module nihil.cli;
-
-/*
- * command_map represents a hierarchical list of commands. At each level,
- * a command is mapped to a handler, which can either be a function, in
- * which case we execute the function, or another command_map, in which
- * case we invoke the new map
- */
-
-namespace nihil {
-
-/*
- * The string tree we store our commands in. This is sort of like a very
- * basic hierarchical std::map. Keys are provided as a range of values,
- * typically from argv.
- */
-
-struct command_tree_node final {
- command_tree_node()
- : _this_word("")
- {
- }
-
- command_tree_node(std::string_view this_word)
- : _this_word(this_word)
- {
- }
-
- command_tree_node(std::string_view this_word,
- command value)
- : _this_word(this_word)
- , _value(std::move(value))
- {
- }
-
- /*
- * Return a child node, or NULL if the child doesn't exist.
- */
- auto get_child(this command_tree_node const &self,
- std::string_view child)
- -> command_tree_node const *
- {
- if (auto it = self.children.find(std::string(child));
- it != self.children.end())
- return &it->second;
-
- return nullptr;
- }
-
- auto get_child(this command_tree_node &self,
- std::string_view child)
- -> command_tree_node *
- {
- if (auto it = self.children.find(std::string(child));
- it != self.children.end())
- return &it->second;
-
- return nullptr;
- }
-
- /*
- * Return a child node if it exists, or insert a new empty node.
- */
- auto get_or_create_child(this command_tree_node &self,
- std::string_view child)
- -> command_tree_node *
- {
- if (auto ptr = self.get_child(child); ptr != nullptr)
- return ptr;
-
- auto [it, ok] = self.children.emplace(
- child, command_tree_node(child));
- return &it->second;
- }
-
- /*
- * Return this node's value.
- */
- auto value(this command_tree_node const &self)
- -> std::optional<command> const &
- {
- return self._value;
- }
-
- /*
- * Set this node's value.
- */
- auto value(this command_tree_node &self, command value) -> void
- {
- self._value = std::move(value);
- }
-
- /*
- * Print this node's children in a form useful to humans.
- */
- auto print_commands(this command_tree_node const &self,
- std::string_view prefix) -> void
- {
- for (auto &&[name, node] : self.children) {
- std::print(" {} {}\n", prefix, name);
- }
- }
-
-private:
- std::string _this_word;
- std::optional<command> _value;
- std::unordered_map<std::string, command_tree_node> children;
-};
-
-struct command_tree {
- /*
- * Add a node to the tree. Returns false if the node already exists.
- */
- auto insert(this command_tree &self,
- std::ranges::range auto &&path,
- command value)
- -> bool
- {
- auto *this_node = &self._root_node;
-
- // Find the node for this key.
- for (auto &&next : path) {
- auto this_word = std::string_view(next);
- this_node = this_node->get_or_create_child(this_word);
- }
-
- if (this_node->value()) {
- // The value already exists.
- return false;
- }
-
- // Set the new value.
- this_node->value(std::move(value));
- return true;
- }
-
- /*
- * Find a node in the tree. Unlike insert(), iteration stops when
- * we find any node with a value.
- */
- auto find(this command_tree const &self, int &argc, char **&argv)
- -> std::optional<command>
- {
- auto *this_node = &self._root_node;
-
- // Assume the caller already stripped the program name from
- // argv. This is usually the case since they would have
- // called getopt().
-
- // Store the command bits we got so far, so we can print them
- // in the usage error if needed.
- auto path = std::string();
-
- while (argv[0] != nullptr) {
- auto next = std::string_view(argv[0]);
-
- auto *next_node = this_node->get_child(next);
-
- if (next_node == nullptr) {
- // The node doesn't exist, so this command is
- // not valid. Print a list of valid commands.
- std::print(std::cerr,
- "{}: unknown command: {} {}\n",
- ::getprogname(), path, next);
- std::print(std::cerr,
- "{}: expected one of:\n",
- ::getprogname());
-
- this_node->print_commands(path);
- return {};
- }
-
- this_node = next_node;
-
- if (this_node->value())
- // This node has a value, so return it.
- return {this_node->value()};
-
- if (!path.empty())
- path += ' ';
- path += next;
- --argc;
- ++argv;
- }
-
- // We didn't find a value, so the key was incomplete.
- std::print(std::cerr, "{}: {} command; expected:\n",
- ::getprogname(),
- path.empty() ? "missing" : "incomplete");
- this_node->print_commands(path);
-
- return {};
- }
-
-private:
- command_tree_node _root_node;
-};
-
-/*
- * The global command map.
- */
-auto get_commands() -> command_tree & {
- static auto commands = command_tree();
- return commands;
-}
-
-auto register_command(std::string_view path, command *cmd) noexcept -> void
-try {
- auto &commands = get_commands();
- if (commands.insert(path | std::views::split(' '), *cmd) == false) {
- std::printf("command registration failed\n");
- std::abort();
- }
-} catch (...) {
- std::printf("command registration failed\n");
- std::abort();
-}
-
-auto dispatch_command(int argc, char **argv) -> int
-{
- auto &commands = get_commands();
-
- // The caller should have stripped argv[0] already. find() will
- // strip all the remaining elements except the last, which means
- // argv[0] will be set to something reasonable for the next call
- // to getopt().
-
- auto node = commands.find(argc, argv);
-
- if (!node)
- // find() already printed the error message
- return 1;
-
- // Reset getopt here for the command to use.
- optreset = 1;
- optind = 1;
-
- // Calling setprogname() makes error messages more relevant.
- auto cprogname = std::format("{} {}", ::getprogname(),
- node->path());
- ::setprogname(cprogname.c_str());
-
- return node->invoke(argc, argv);
-}
-
-void print_usage(std::string_view)
-{
-// get_root_node().print_usage(std::string(prefix));
-}
-
-} // namespace nihil
diff --git a/nihil.cli/command_map.ccm b/nihil.cli/command_map.ccm
deleted file mode 100644
index 8cf9d9c..0000000
--- a/nihil.cli/command_map.ccm
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * This source code is released into the public domain.
- */
-
-module;
-
-#include <functional>
-#include <iostream>
-#include <map>
-#include <string>
-#include <utility>
-
-export module nihil.cli:command_map;
-
-import nihil.util;
-import :usage_error;
-
-/*
- * command_map represents a hierarchical list of commands. At each level,
- * a command is mapped to a handler, which can either be a function, in
- * which case we execute the function, or another command_map, in which
- * case we invoke the new map
- */
-
-namespace nihil {
-
-export struct command;
-
-/*
- * Register a command; used by command<>::command().
- */
-auto register_command(std::string_view path, command *) noexcept -> void;
-
-/*
- * A command that can be invoked. Instantiating a command adds this command
- * to the global command table. If an error occurs, the program will abort.
- */
-export struct command {
- command(std::string_view path, std::string_view usage, auto &&fn)
- : m_path(path)
- , m_usage(usage)
- , m_handler(std::forward<decltype(fn)>(fn))
- {
- register_command(path, this);
- }
-
- [[nodiscard]] auto path(this command const &self) -> std::string_view
- {
- return self.m_path;
- }
-
- auto invoke(this command const &self, int argc, char **argv) -> int
- {
- return std::invoke(self.m_handler, argc, argv);
- }
-
-private:
- std::string_view m_path;
- std::string_view m_usage;
- std::function<int (int, char **)> m_handler;
-};
-
-// The public API.
-export [[nodiscard]] auto dispatch_command(int argc, char **argv) -> int;
-export auto print_usage(std::string_view prefix) -> void;
-
-} // namespace nihil
diff --git a/nihil.cli/command_node.cc b/nihil.cli/command_node.cc
new file mode 100644
index 0000000..98aeac1
--- /dev/null
+++ b/nihil.cli/command_node.cc
@@ -0,0 +1,42 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <expected>
+#include <iostream>
+#include <print>
+#include <string>
+
+module nihil.cli;
+
+import nihil.core;
+import nihil.error;
+
+namespace nihil {
+
+command_node::command_node(std::string_view path) noexcept
+ : m_path(path)
+{
+}
+
+command_node::~command_node()
+{
+}
+
+auto command_node::path(this command_node const &self) noexcept
+ -> std::string_view
+{
+ return self.m_path;
+}
+
+auto command_node::invoke(int, char **) const
+ -> std::expected<int, error>
+{
+ // If invoke() wasn't overridden, then this is an empty node,
+ // so the command was incomplete.
+ return std::unexpected(error(errc::incomplete_command));
+}
+
+} // namespace nihil
diff --git a/nihil.cli/command_node.ccm b/nihil.cli/command_node.ccm
new file mode 100644
index 0000000..546eb46
--- /dev/null
+++ b/nihil.cli/command_node.ccm
@@ -0,0 +1,35 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+/*
+ * command_node represents a possibly-invocable command.
+ */
+
+#include <expected>
+#include <string>
+
+export module nihil.cli:command_node;
+
+import nihil.error;
+
+namespace nihil {
+
+export struct command_node {
+ command_node(std::string_view path) noexcept;
+
+ virtual ~command_node();
+
+ [[nodiscard]] auto path(this command_node const &) noexcept
+ -> std::string_view;
+
+ [[nodiscard]] virtual auto invoke(int argc, char **argv) const
+ -> std::expected<int, error>;
+
+private:
+ std::string m_path;
+};
+
+} // namespace nihil
diff --git a/nihil.cli/command_path.ccm b/nihil.cli/command_path.ccm
new file mode 100644
index 0000000..a5bd5e3
--- /dev/null
+++ b/nihil.cli/command_path.ccm
@@ -0,0 +1,16 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+/*
+ * command_path represents a command split into its component parts.
+ */
+
+export module nihil.cli:command_path;
+
+namespace nihil {
+
+
+} // namespace nihil
diff --git a/nihil.cli/command_tree.cc b/nihil.cli/command_tree.cc
new file mode 100644
index 0000000..4142a40
--- /dev/null
+++ b/nihil.cli/command_tree.cc
@@ -0,0 +1,187 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <iostream>
+#include <print>
+#include <ranges>
+#include <string>
+#include <vector>
+
+module nihil.cli;
+
+namespace nihil {
+
+command_tree_node::command_tree_node()
+ : m_this_word("")
+ , m_command(std::make_shared<command_node>(command_node("")))
+{
+}
+
+command_tree_node::command_tree_node(command_tree_node *parent,
+ std::string_view this_word)
+ : m_parent(parent)
+ , m_this_word(this_word)
+{
+}
+
+command_tree_node::command_tree_node(command_tree_node *parent,
+ std::string_view this_word,
+ std::shared_ptr<command_node> command)
+ : m_parent(parent)
+ , m_this_word(this_word)
+ , m_command(std::move(command))
+{
+}
+
+auto command_tree_node::get_child(this command_tree_node const &self,
+ std::string_view child)
+ -> command_tree_node const *
+{
+ if (auto it = self.m_children.find(std::string(child));
+ it != self.m_children.end())
+ return &it->second;
+
+ return nullptr;
+}
+
+auto command_tree_node::get_child(this command_tree_node &self,
+ std::string_view child)
+ -> command_tree_node *
+{
+ if (auto it = self.m_children.find(std::string(child));
+ it != self.m_children.end())
+ return &it->second;
+
+ return nullptr;
+}
+
+auto command_tree_node::get_or_create_child(this command_tree_node &self,
+ std::string_view child)
+ -> command_tree_node *
+{
+ // Return the existing child, if there is one.
+ if (auto ptr = self.get_child(child); ptr != nullptr)
+ return ptr;
+
+ // Insert a new child.
+ auto [it, ok] = self.m_children.emplace(
+ child,
+ command_tree_node(&self, child));
+
+ // Give the child a dummy command.
+ it->second.m_command = std::make_shared<command_node>(
+ self.path() + ' ' + child);
+
+ return &it->second;
+}
+
+auto command_tree_node::command(this command_tree_node const &self)
+ -> std::shared_ptr<command_node> const &
+{
+ return self.m_command;
+}
+
+auto command_tree_node::command(this command_tree_node &self,
+ std::shared_ptr<command_node> command)
+ -> void
+{
+ // TODO: Put this check back without tripping from the dummy command.
+ //if (self.m_command != nullptr)
+ // throw std::logic_error("duplicate command");
+ self.m_command = std::move(command);
+}
+
+auto command_tree_node::print_commands(this command_tree_node const &self)
+ -> void
+{
+ auto prefix = std::string(self.path());
+
+ for (auto &&[name, node] : self.m_children) {
+ auto command = prefix.empty()
+ ? name
+ : (prefix + ' ' + name);
+ std::print(std::cerr, " {}\n", command);
+ }
+}
+
+auto command_tree_node::path(this command_tree_node const &self)
+ -> std::string
+{
+ auto path = std::string();
+
+ auto const *node = &self;
+ while (node->m_parent != nullptr) {
+ path = node->m_this_word + ' ' + path;
+ node = node->m_parent;
+ }
+
+ // Trim the trailing space.
+ if (!path.empty())
+ path.pop_back();
+
+ return path;
+}
+
+auto command_tree::insert(this command_tree &self,
+ std::vector<std::string_view> const &path,
+ std::shared_ptr<command_node> command)
+ -> void
+{
+ auto *this_node = &self.m_root_node;
+
+ // Find the node for this key.
+ for (auto &&this_word : path)
+ this_node = this_node->get_or_create_child(this_word);
+
+ // Set the new value.
+ this_node->command(std::move(command));
+}
+
+auto command_tree::find(this command_tree const &self, int &argc, char **&argv)
+ -> command_tree_node const *
+{
+ auto *this_node = &self.m_root_node;
+
+ // Iterate until we don't find a child command, then return that node.
+ while (argv[0] != nullptr) {
+ auto *next_node = this_node->get_child(argv[0]);
+
+ if (next_node == nullptr)
+ return this_node;
+
+ this_node = next_node;
+
+ --argc;
+ ++argv;
+ }
+
+ // We ran out of path without finding a valid command. Return this
+ // node; the caller will notice the missing command.
+ return this_node;
+}
+
+auto build_command_tree() -> command_tree
+{
+ auto const &commands = get_registered_commands();
+ auto tree = command_tree();
+
+ for (auto &&command : commands) {
+ auto split_path = std::vector<std::string_view>(
+ std::from_range,
+ command->path()
+ | std::views::split(' ')
+ | std::views::transform([] (auto &&r) {
+ return std::string_view(r);
+ }));
+
+ // Throws std::logic_error on duplicates.
+ tree.insert(split_path, command);
+ }
+
+ return tree;
+}
+
+} // namespace nihil
diff --git a/nihil.cli/command_tree.ccm b/nihil.cli/command_tree.ccm
new file mode 100644
index 0000000..f52b768
--- /dev/null
+++ b/nihil.cli/command_tree.ccm
@@ -0,0 +1,107 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <map>
+#include <memory>
+#include <optional>
+#include <ranges>
+#include <vector>
+
+export module nihil.cli:command_tree;
+
+import :command;
+
+namespace nihil {
+
+/*
+ * command_tree_node represents a possibly-empty node in the command tree.
+ * For example, if two commands "add foo" and "add bar" are defined,
+ * then "add" will be implicitly created as an empty node.
+ */
+struct command_tree_node final {
+ command_tree_node();
+
+ command_tree_node(command_tree_node *parent,
+ std::string_view this_word);
+
+ command_tree_node(command_tree_node *parent,
+ std::string_view this_word,
+ std::shared_ptr<command_node> command);
+
+ /*
+ * Return a child node, or NULL if the child doesn't exist.
+ */
+ [[nodiscard]] auto get_child(this command_tree_node const &self,
+ std::string_view child)
+ -> command_tree_node const *;
+
+ [[nodiscard]] auto get_child(this command_tree_node &self,
+ std::string_view child)
+ -> command_tree_node *;
+
+ /*
+ * Return a child node if it exists, or insert a new empty node.
+ */
+ [[nodiscard]] auto get_or_create_child(this command_tree_node &self,
+ std::string_view child)
+ -> command_tree_node *;
+
+ /*
+ * Set or get this node's command.
+ */
+ [[nodiscard]] auto command(this command_tree_node const &self)
+ -> std::shared_ptr<command_node> const &;
+ auto command(this command_tree_node &self,
+ std::shared_ptr<command_node>)
+ -> void;
+
+ /*
+ * Get the path of this command_node.
+ */
+ [[nodiscard]] auto path(this command_tree_node const &self)
+ -> std::string;
+
+ /*
+ * Print this node's children in a form useful to humans.
+ */
+ auto print_commands(this command_tree_node const &self) -> void;
+
+private:
+ command_tree_node *m_parent = nullptr;
+ std::string m_this_word;
+ std::shared_ptr<command_node> m_command;
+ std::map<std::string, command_tree_node>
+ m_children;
+};
+
+/*
+ * The command tree stores commands in a tree structure suitable for searching.
+ */
+struct command_tree {
+ /*
+ * Add a node to the tree. Returns false if the node already exists.
+ */
+ auto insert(this command_tree &self,
+ std::vector<std::string_view> const &path,
+ std::shared_ptr<command_node> command)
+ -> void;
+
+ /*
+ * Find a node in the tree.
+ */
+ auto find(this command_tree const &self, int &argc, char **&argv)
+ -> command_tree_node const *;
+
+private:
+ command_tree_node m_root_node;
+};
+
+/*
+ * Build a command tree from the registry.
+ */
+[[nodiscard]] auto build_command_tree() -> command_tree;
+
+} // namespace nihil
diff --git a/nihil.cli/dispatch_command.cc b/nihil.cli/dispatch_command.cc
new file mode 100644
index 0000000..736e16e
--- /dev/null
+++ b/nihil.cli/dispatch_command.cc
@@ -0,0 +1,82 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <cstdio>
+#include <functional>
+#include <iostream>
+#include <map>
+#include <print>
+#include <ranges>
+#include <string>
+#include <utility>
+
+#include <unistd.h>
+
+module nihil.cli;
+
+import nihil.core;
+
+namespace nihil {
+
+auto dispatch_command(int argc, char **argv) -> int
+{
+ auto tree = build_command_tree();
+
+ // The caller should have stripped argv[0] already. find() will
+ // strip all the remaining elements except the last, which means
+ // argv[0] will be set to something reasonable for the next call
+ // to getopt().
+
+ // find() never returns nullptr; at worst it will return the
+ // root node.
+ auto const *node = tree.find(argc, argv);
+
+ // Get the command_node.
+ auto const &command = node->command();
+
+ // Reset getopt(3) for the command, in case main() used it already.
+ optreset = 1;
+ optind = 1;
+
+ /*
+ * Set the program name to the existing progname plus the full path
+ * to the command being invoked; this makes error messages nicer.
+ */
+ auto *old_progname = ::getprogname();
+
+ {
+ auto cprogname = std::format("{} {}", ::getprogname(),
+ command->path());
+ ::setprogname(cprogname.c_str());
+ }
+
+ // Invoke the command see what it returns.
+ auto ret = command->invoke(argc, argv);
+
+ // Restore the old progname.
+ ::setprogname(old_progname);
+
+ // If the command produced an exit code, return it.
+ if (ret)
+ return *ret;
+
+ /*
+ * We have special handling for some errors.
+ */
+
+ // Incomplete command: print the list of valid commands at this node.
+ if (ret.error() == errc::incomplete_command) {
+ std::print(std::cerr, "{}: usage:\n", ::getprogname());
+ node->print_commands();
+ return 1;
+ }
+
+ // We didn't recognise the error, so just print it and exit.
+ std::print(std::cerr, "{}\n", ret.error());
+ return 1;
+}
+
+} // namespace nihil
diff --git a/nihil.cli/dispatch_command.ccm b/nihil.cli/dispatch_command.ccm
new file mode 100644
index 0000000..1ba55bb
--- /dev/null
+++ b/nihil.cli/dispatch_command.ccm
@@ -0,0 +1,31 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <functional>
+#include <iostream>
+#include <map>
+#include <string>
+#include <utility>
+
+export module nihil.cli:dispatch_command;
+
+import nihil.util;
+import :command;
+import :usage_error;
+
+namespace nihil {
+
+/*
+ * Invoke a command (which must have been previously registered) using
+ * the provided argument vector.
+ *
+ * The caller should have already stripped the executable name from argv[0]
+ * so that the vector starts with the command name. This is implicitly
+ * done if main() uses getopt().
+ */
+export [[nodiscard]] auto dispatch_command(int argc, char **argv) -> int;
+
+} // namespace nihil
diff --git a/nihil.cli/nihil.cli.ccm b/nihil.cli/nihil.cli.ccm
index faeb44e..6d98c05 100644
--- a/nihil.cli/nihil.cli.ccm
+++ b/nihil.cli/nihil.cli.ccm
@@ -6,4 +6,9 @@ module;
export module nihil.cli;
-export import :command_map;
+export import :command;
+export import :command_tree;
+export import :command_node;
+export import :dispatch_command;
+export import :registry;
+export import :usage_error;
diff --git a/nihil.cli/registry.cc b/nihil.cli/registry.cc
new file mode 100644
index 0000000..e35078d
--- /dev/null
+++ b/nihil.cli/registry.cc
@@ -0,0 +1,57 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <cstdio>
+#include <exception>
+#include <memory>
+#include <vector>
+
+module nihil.cli;
+
+namespace nihil {
+
+/*
+ * Get the registry storage. Because this is called from global ctors,
+ * it handles exceptions itself.
+ */
+auto get_registry() noexcept -> std::vector<std::shared_ptr<command_node>> &
+try {
+ static auto commands = std::vector<std::shared_ptr<command_node>>();
+ return commands;
+} catch (std::exception const &exc) {
+ std::printf("%s\n", exc.what());
+ std::exit(1);
+} catch (...) {
+ std::printf("get_registered_commands(): unknown error\n");
+ std::exit(1);
+}
+
+/*
+ * Register a new command.
+ */
+auto register_command(command *cmd) noexcept -> void
+try {
+ auto null_deleter = [] (command_node const *) -> void {};
+
+ auto &commands = get_registry();
+ commands.emplace_back(cmd, null_deleter);
+} catch (std::exception const &exc) {
+ std::printf("%s\n", exc.what());
+ std::exit(1);
+} catch (...) {
+ std::printf("get_registered_commands(): unknown error\n");
+ std::exit(1);
+}
+
+/*
+ * Get the list of registered commands.
+ */
+auto get_registered_commands() -> std::span<std::shared_ptr<command_node>>
+{
+ return {get_registry()};
+}
+
+} // namespace nihil
diff --git a/nihil.cli/registry.ccm b/nihil.cli/registry.ccm
new file mode 100644
index 0000000..0b9754d
--- /dev/null
+++ b/nihil.cli/registry.ccm
@@ -0,0 +1,28 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <memory>
+#include <span>
+
+export module nihil.cli:registry;
+
+namespace nihil {
+
+export struct command;
+export struct command_node;
+
+/*
+ * Register a command. This is guaranteed not to throw; errors will print
+ * a diagnostic and exit.
+ */
+auto register_command(command *cmd) noexcept -> void;
+
+/*
+ * Get previously registered commands.
+ */
+auto get_registered_commands() -> std::span<std::shared_ptr<command_node>>;
+
+} // namespace nihil
diff --git a/nihil.cli/test.cc b/nihil.cli/test.cc
new file mode 100644
index 0000000..c265700
--- /dev/null
+++ b/nihil.cli/test.cc
@@ -0,0 +1,100 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+#include <iostream>
+#include <vector>
+
+#include <catch2/catch_test_macros.hpp>
+
+import nihil.cli;
+import nihil.util;
+
+namespace {
+
+auto cmd_sub1_called = false;
+auto cmd_sub1 = nihil::command("cmd sub1", "", [](int, char **) -> int
+{
+ cmd_sub1_called = true;
+ return 0;
+});
+
+auto cmd_sub2_called = false;
+auto cmd_sub2 = nihil::command("cmd sub2", "", [](int, char **) -> int
+{
+ cmd_sub2_called = true;
+ return 0;
+});
+
+} // anonymous namespace
+
+TEST_CASE("nihil.cli: dispatch_command: basic", "[nihil.cli]")
+{
+ SECTION("cmd sub1") {
+ auto args = std::vector<char const *>{
+ "cmd", "sub1", nullptr
+ };
+ auto argv = const_cast<char **>(args.data());
+
+ int ret = nihil::dispatch_command(args.size() - 1, argv);
+ REQUIRE(ret == 0);
+ REQUIRE(cmd_sub1_called == true);
+ REQUIRE(cmd_sub2_called == false);
+ }
+
+ SECTION("cmd sub2") {
+ auto args = std::vector<char const *>{
+ "cmd", "sub2", nullptr
+ };
+ auto argv = const_cast<char **>(args.data());
+
+ int ret = nihil::dispatch_command(args.size() - 1, argv);
+ REQUIRE(ret == 0);
+ REQUIRE(cmd_sub2_called == true);
+ }
+}
+
+TEST_CASE("nihil.cli: dispatch_command: unknown command", "[nihil.cli]")
+{
+ auto args = std::vector<char const *>{
+ "nocomd", "sub", nullptr
+ };
+ auto argv = const_cast<char **>(args.data());
+
+ auto output = std::string();
+ auto ret = int{};
+ {
+ auto capture = nihil::capture_stream(std::cerr);
+ ret = nihil::dispatch_command(args.size() - 1, argv);
+ std::cerr.flush();
+ output = capture.str();
+ }
+
+ REQUIRE(ret == 1);
+
+ auto *progname = ::getprogname();
+ REQUIRE(output == std::format("{}: usage:\n cmd\n", progname));
+}
+
+TEST_CASE("nihil.cli: dispatch_command: incomplete command", "[nihil.cli]")
+{
+ auto args = std::vector<char const *>{
+ "cmd", nullptr
+ };
+ auto argv = const_cast<char **>(args.data());
+
+ auto output = std::string();
+ auto ret = int{};
+ {
+ auto capture = nihil::capture_stream(std::cerr);
+ ret = nihil::dispatch_command(args.size() - 1, argv);
+ std::cerr.flush();
+ output = capture.str();
+ }
+
+ REQUIRE(ret == 1);
+
+ auto *progname = ::getprogname();
+ REQUIRE(output == std::format("{}: usage:\n cmd sub1\n cmd sub2\n",
+ progname));
+}
diff --git a/nihil.cli/test_command_map.cc b/nihil.cli/test_command_map.cc
deleted file mode 100644
index 1b87a49..0000000
--- a/nihil.cli/test_command_map.cc
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * This source code is released into the public domain.
- */
-
-#include <vector>
-
-#include <catch2/catch_test_macros.hpp>
-
-import nihil.cli;
-
-namespace {
-
-auto cmd_sub1_called = false;
-auto cmd_sub1 = nihil::command("cmd sub1", "", [](int, char **) -> int
-{
- cmd_sub1_called = true;
- return 0;
-});
-
-} // anonymous namespace
-
-TEST_CASE("command_map: basic", "[command_map]")
-{
- auto args = std::vector<char const *>{
- "cmd", "sub1", nullptr
- };
- auto argv = const_cast<char **>(args.data());
-
- int ret = nihil::dispatch_command(args.size() - 1, argv);
- REQUIRE(ret == 0);
- REQUIRE(cmd_sub1_called == true);
-}
diff --git a/nihil.cli/usage_error.ccm b/nihil.cli/usage_error.ccm
index 11646e6..61feba7 100644
--- a/nihil.cli/usage_error.ccm
+++ b/nihil.cli/usage_error.ccm
@@ -20,5 +20,3 @@ export struct usage_error : error {
};
} // namespace nihil
-
-