From 0477c76c46f872340e1c0a3987ee17e0114823ff Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 00:31:32 +0800 Subject: [PATCH 1/8] feat: add shell completions skeleton --- src/cmdline.cppm | 58 ++++++++++++++++++++++++++++++++++ src/completions.cppm | 66 +++++++++++++++++++++++++++++++++++++++ src/completions/bash.cppm | 14 +++++++++ src/completions/fish.cppm | 14 +++++++++ src/completions/zsh.cppm | 14 +++++++++ 5 files changed, 166 insertions(+) create mode 100644 src/completions.cppm create mode 100644 src/completions/bash.cppm create mode 100644 src/completions/fish.cppm create mode 100644 src/completions/zsh.cppm diff --git a/src/cmdline.cppm b/src/cmdline.cppm index ec8b14e..4d05e0e 100644 --- a/src/cmdline.cppm +++ b/src/cmdline.cppm @@ -2,9 +2,14 @@ export module mcpplibs.cmdline; import std; +export import :completions; export import :options; export import :parse; +export import :completions.bash; +export import :completions.fish; +export import :completions.zsh; + namespace mcpplibs::cmdline { class SubcommandBuilder; @@ -25,6 +30,7 @@ export class App { friend class SubcommandBuilder; friend class OptBuilder; friend class ArgBuilder; + friend completions::Command snapshot(const App&); public: explicit App(std::string_view name) : name_(name) {} App(App&&) noexcept = default; @@ -161,6 +167,8 @@ public: } } + [[nodiscard]] std::string completions(Shell shell) const; + private: using global_keys_t = std::set>; using global_opts_t = std::map>; @@ -493,4 +501,54 @@ SubcommandBuilder AppItemBuilder::subcommand(std::string_view name) { this->c inline OptBuilder SubcommandBuilder::option(std::string_view name) { return OptBuilder(&sub_, Option(name)); } inline SubcommandArgBuilder SubcommandBuilder::arg(std::string_view name) { return SubcommandArgBuilder(this, Arg(name)); } +/// Rebuild the command tree for shell completions. +completions::Command snapshot(const App& app) { + completions::Command cmd; + cmd.name = app.name_; + cmd.description = app.description_; + cmd.version = app.version_; + + cmd.args = app.args_; + cmd.options = app.options_; + + // Special cases in `App::parse_impl`: + // + // `-h/--help` is always available. + cmd.options.emplace_back(detail::Option("help").short_name('h').help("Print help").global()); + // `--version` is available when app has a non-empty version. + if (!cmd.version.empty()) cmd.options.emplace_back(detail::Option("version").help("Print version").global()); + + for (const auto& sub : app.subcommands_) cmd.subcommands.emplace_back(snapshot(sub)); + + return cmd; +} + +export void generate_completions(const completions::Command& cmd, Shell shell, std::ostream& out) { + switch (shell) { + case Shell::bash: completions::bash::generate(cmd, out); return; + case Shell::fish: completions::fish::generate(cmd, out); return; + case Shell::zsh: completions::zsh::generate(cmd, out); return; + } +} + +export std::string generate_completions(const completions::Command& cmd, Shell shell) { + std::ostringstream oss; + generate_completions(cmd, shell, oss); + return oss.str(); +} + +export void generate_completions(const App& app, Shell shell, std::ostream& out) { + auto cmd = snapshot(app); + generate_completions(cmd, shell, out); +} + +export std::string generate_completions(const App& app, Shell shell) { + auto cmd = snapshot(app); + return generate_completions(cmd, shell); +} + +std::string App::completions(Shell shell) const { + return generate_completions(*this, shell); +} + } // namespace mcpplibs::cmdline diff --git a/src/completions.cppm b/src/completions.cppm new file mode 100644 index 0000000..3a42fed --- /dev/null +++ b/src/completions.cppm @@ -0,0 +1,66 @@ +module; + +export module mcpplibs.cmdline:completions; + +import std; +import :options; + +namespace mcpplibs::cmdline { + +export enum class Shell { + bash, + fish, + zsh, +}; + +struct ShellEntry { + Shell value; + std::string_view name; +}; + +constexpr std::array shell_entries {{ + {Shell::bash, "bash"}, + {Shell::fish, "fish"}, + {Shell::zsh, "zsh"}, +}}; + +export [[nodiscard]] std::optional shell_from_string(std::string_view sv) { + for (const auto& [value, name] : shell_entries) { + if (name == sv) return value; + } + return std::nullopt; +} + +export [[nodiscard]] std::string_view to_string(Shell shell) { + for (const auto& [value, name] : shell_entries) { + if (value == shell) return name; + } + return {}; +} + +export [[nodiscard]] bool shell_supported(Shell shell) { + switch (shell) { + case Shell::fish: return true; + case Shell::bash: + case Shell::zsh: return false; + } + return false; +} + +}; // namespace mcpplibs::cmdline + +namespace mcpplibs::cmdline::completions { + +export struct Command { + std::string name; + std::string description; + std::string version; + std::vector args; + std::vector options; + std::vector subcommands; + + [[nodiscard]] bool has_positionals() const { return !args.empty(); } + [[nodiscard]] bool has_subcommands() const { return !subcommands.empty(); } +}; + +}; // namespace mcpplibs::cmdline::completions diff --git a/src/completions/bash.cppm b/src/completions/bash.cppm new file mode 100644 index 0000000..1aae080 --- /dev/null +++ b/src/completions/bash.cppm @@ -0,0 +1,14 @@ +module; + +export module mcpplibs.cmdline:completions.bash; + +import std; +import :completions; + +namespace mcpplibs::cmdline::completions::bash { + +export void generate(const completions::Command& /*cmd*/, std::ostream& /*out*/) { + // TODO +} + +}; // namespace mcpplibs::cmdline::completions::bash diff --git a/src/completions/fish.cppm b/src/completions/fish.cppm new file mode 100644 index 0000000..16f60b7 --- /dev/null +++ b/src/completions/fish.cppm @@ -0,0 +1,14 @@ +module; + +export module mcpplibs.cmdline:completions.fish; + +import std; +import :completions; + +namespace mcpplibs::cmdline::completions::fish { + +export void generate(const completions::Command& /*cmd*/, std::ostream& /*out*/) { + // TODO +} + +}; // namespace mcpplibs::cmdline::completions::fish diff --git a/src/completions/zsh.cppm b/src/completions/zsh.cppm new file mode 100644 index 0000000..9fe32bf --- /dev/null +++ b/src/completions/zsh.cppm @@ -0,0 +1,14 @@ +module; + +export module mcpplibs.cmdline:completions.zsh; + +import std; +import :completions; + +namespace mcpplibs::cmdline::completions::zsh { + +export void generate(const completions::Command& /*cmd*/, std::ostream& /*out*/) { + // TODO +} + +}; // namespace mcpplibs::cmdline::completions::zsh From 3e53410b0aaa09ead1ead605be673774ed626efb Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 11:11:07 +0800 Subject: [PATCH 2/8] feat(completions): add fish support --- src/completions/fish.cppm | 216 +++++++++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 2 deletions(-) diff --git a/src/completions/fish.cppm b/src/completions/fish.cppm index 16f60b7..48da3c8 100644 --- a/src/completions/fish.cppm +++ b/src/completions/fish.cppm @@ -5,10 +5,222 @@ export module mcpplibs.cmdline:completions.fish; import std; import :completions; +namespace { + +/// Replace '-' with '_' in fish function names. +[[nodiscard]] std::string escape_name(std::string_view name) { + std::string result; + result.reserve(name.size()); + for (char c : name) result += (c == '-') ? '_' : c; + return result; +} + +/// Escape string inside single quotes. +[[nodiscard]] std::string escape_string(std::string_view s) { + std::string result; + result.reserve(s.size()); + for (char c : s) { + if (c == '\\') + result += "\\\\"; + else if (c == '\'') + result += "\\'"; + else if (c == '\n') + result += ' '; + else + result += c; + } + return result; +} + +/// Generate an argparse optspec string for one option. +[[nodiscard]] std::string optspec_for_option(const mcpplibs::cmdline::detail::Option& opt) { + std::string spec; + if (opt.short_) { + spec += opt.short_; + if (!opt.long_name.empty()) { + spec += '/'; + spec += opt.long_name; + } + } else { + spec += opt.long_name; + } + if (opt.takes_value_) spec += '='; + return spec; +} + +/// Build the condition string for `-n`. +[[nodiscard]] std::string build_condition( + std::string_view escaped, + const std::vector& parent_path, + std::span child_names) +{ + if (parent_path.empty()) return std::format("__fish_{}_needs_command", escaped); + + if (parent_path.size() == 1) { + auto condition = std::format("__fish_{}_using_subcommand {}", escaped, parent_path[0]); + if (!child_names.empty()) { + condition += "; and not __fish_seen_subcommand_from"; + for (const auto& child_name : child_names) { + condition += ' '; + condition += child_name; + } + } + return condition; + } + + if (parent_path.size() == 2) { + return std::format( + "__fish_{}_using_subcommand {}; and __fish_seen_subcommand_from {}", + escaped, parent_path[0], parent_path[1] + ); + } + + // HACK: Cases with depth >= 3 take much effort to support. Emit nothing. + return {}; +} + +void gen_subcommand_helpers( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view escaped, + std::ostream& out) +{ + std::vector optspecs; + optspecs.reserve(cmd.options.size()); + for (const auto& opt : cmd.options) optspecs.emplace_back(optspec_for_option(opt)); + + out << "function __fish_" << escaped << "_global_optspecs\n"; + out << " string join \\n"; + for (const auto& optspec : optspecs) out << ' ' << optspec; + out << '\n'; + out << "end\n\n"; + + out << "function __fish_" << escaped << "_needs_command\n"; + out << " set -l cmd (commandline -opc)\n"; + out << " set -e cmd[1]\n"; + out << " argparse -s (__fish_" << escaped << "_global_optspecs) -- $cmd 2>/dev/null\n"; + out << " or return\n"; + out << " if set -q argv[1]\n"; + out << " echo $argv[1]\n"; + out << " return 1\n"; + out << " end\n"; + out << " return 0\n"; + out << "end\n\n"; + + out << "function __fish_" << escaped << "_using_subcommand\n"; + out << " set -l cmd (__fish_" << escaped << "_needs_command)\n"; + out << " test -z \"$cmd\"\n"; + out << " and return 1\n"; + out << " contains -- $cmd[1] $argv\n"; + out << "end\n\n"; +} + +}; // unnamed namespace + namespace mcpplibs::cmdline::completions::fish { -export void generate(const completions::Command& /*cmd*/, std::ostream& /*out*/) { - // TODO +void emit_option_line( + std::string_view root_name, + std::string_view condition, + const detail::Option& opt, + std::ostream& out) +{ + out << "complete -c " << root_name; + if (!condition.empty()) out << " -n '" << condition << '\''; + if (opt.short_) out << " -s " << opt.short_; + if (!opt.long_name.empty()) out << " -l " << opt.long_name; + if (opt.takes_value_) out << " -r"; + if (!opt.help_.empty()) out << " -d '" << escape_string(opt.help_) << '\''; + out << '\n'; +} + +void emit_subcommand_line( + std::string_view root_name, + std::string_view condition, + const Command& sub, + bool has_positionals, + std::ostream& out) +{ + out << "complete -c " << root_name; + if (!condition.empty()) out << " -n '" << condition << '\''; + if (!has_positionals) out << " -f"; + out << " -a \"" << sub.name << '"'; + if (!sub.description.empty()) out << " -d '" << escape_string(sub.description) << '\''; + out << '\n'; +} + +void gen_inner( + std::string_view root_name, + std::string_view escaped, + const std::vector& parent_path, + const Command& node, + std::span root_globals, + std::ostream& out) +{ + std::vector child_names; + child_names.reserve(node.subcommands.size()); + for (const auto& sub : node.subcommands) child_names.emplace_back(sub.name); + + auto condition = build_condition(escaped, parent_path, child_names); + if (condition.empty()) { + // Depth >= 3: it takes much effort to support. Emit nothing. + return; + } + + // Options for this node + for (const auto& opt : node.options) emit_option_line(root_name, condition, opt, out); + + // Global re-offer (non-root) + if (!parent_path.empty()) { + for (const auto& rg : root_globals) { + // Skip if this node already has an option with the same long or short name. + bool already_present = false; + const auto& rg_long = rg.long_name; + char rg_short = rg.short_; + for (const auto& existing : node.options) { + if ((!rg_long.empty() && existing.long_name == rg_long) || + (!rg_short && existing.short_ == rg_short)) + { + already_present = true; + break; + } + } + if (!already_present) emit_option_line(root_name, condition, rg, out); + } + } + + // Subcommands for this node + for (const auto& sub : node.subcommands) emit_subcommand_line(root_name, condition, sub, node.has_positionals(), out); + + // Recurse into children + for (const auto& sub : node.subcommands) { + auto child_path = parent_path; + child_path.emplace_back(sub.name); + gen_inner(root_name, escaped, child_path, sub, root_globals, out); + } +} + + +/// Emit one `complete` line per option. No `-n` condition. No helpers. +void generate_simple(const completions::Command& cmd, std::ostream& out) { + for (const auto& opt : cmd.options) emit_option_line(cmd.name, {}, opt, out); +} + + +export void generate(const Command& cmd, std::ostream& out) { + if (!cmd.has_subcommands()) { + generate_simple(cmd, out); + return; + } + + // Collect root globals for re-offer under subcommands. + std::vector root_globals; + for (const auto& opt : cmd.options) { + if (opt.global_) root_globals.emplace_back(opt); + } + + auto escaped = escape_name(cmd.name); + gen_subcommand_helpers(cmd, escaped, out); + gen_inner(cmd.name, escaped, {}, cmd, root_globals, out); } }; // namespace mcpplibs::cmdline::completions::fish From aac305fa46dddc718cbd96bdd5743e45cbf807bb Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 11:49:21 +0800 Subject: [PATCH 3/8] docs: add shell completions example --- examples/completions/.gitignore | 1 + examples/completions/mcpp.toml | 8 ++ examples/completions/src/main.cpp | 143 ++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 examples/completions/.gitignore create mode 100644 examples/completions/mcpp.toml create mode 100644 examples/completions/src/main.cpp diff --git a/examples/completions/.gitignore b/examples/completions/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/examples/completions/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/examples/completions/mcpp.toml b/examples/completions/mcpp.toml new file mode 100644 index 0000000..296176a --- /dev/null +++ b/examples/completions/mcpp.toml @@ -0,0 +1,8 @@ +[package] +name = "completions" +version = "0.1.0" +description = "A mimicked xlings using mcpplibs.cmdline with shell completions" +license = "Apache-2.0" + +[dependencies] +cmdline = { path = "../../" } diff --git a/examples/completions/src/main.cpp b/examples/completions/src/main.cpp new file mode 100644 index 0000000..da6dc0d --- /dev/null +++ b/examples/completions/src/main.cpp @@ -0,0 +1,143 @@ +import std; +import mcpplibs.cmdline; + +using namespace mcpplibs; +using cmdline::App; +using cmdline::Option; +using cmdline::ParsedArgs; + +[[nodiscard]] App create_subos(); + +void dummy_action(const ParsedArgs& p) { + std::println("Options:"); + for (const auto& [key, optvalue] : p.opts) { + std::println(" {} -> count: {}, value: {}", key, optvalue.count, optvalue.value()); + } + + std::println("Positionals:"); + for (const auto& positional : p.positionals) { + std::println(" {}", positional); + } +} + +int main(int argc, char* argv[]) { + // Partially mimicked xlings + auto app = App("completions") + .version("0.1.0") + .author("d2learn community") + .description("A mimicked xlings using mcpplibs.cmdline with shell completions") + // Global options + .option("yes").short_name('y').help("Skip confirmation prompts").global() + .option("verbose").short_name('v').help("Enable verbose output").global() + .option("quiet").short_name('q').help("Suppress non-essential output").global() + .option("agent").help("Plain-text output without TUI formatting (for LLM agents)").global() + // End of Global options + .subcommand("install") + .description("Install packages (e.g. xlings install gcc@15 node)") + .option(Option("global").short_name('g').help("Install to global scope (not project-local subos)")) + .option(Option("use").short_name('u').help("Activate the installed version even if another version is currently active")) + .arg("packages").help("Package names with optional version") + .action(dummy_action) + .subcommand("remove") + .description("Remove a package") + .arg("package").required().help("Package to remove (name or name@ver)") + .arg("version").help("Optional version (alternative to name@ver form)") + .action(dummy_action) + .subcommand("update") + .description("Update package index or a specific package") + .arg("package").help("Package to update (omit for index only)") + .arg("version").help("Optional version (alternative to name@ver form)") + .action(dummy_action) + .subcommand("search") + .description("Search for packages") + .arg("keyword").required().help("Search keyword") + .action(dummy_action) + .subcommand("list") + .description("List installed packages") + .option(Option("all").short_name('a').help("Show packages across all subos (default: current subos only)")) + .arg("filter").help("Filter pattern") + .action(dummy_action) + .subcommand("info") + .description("Show package information") + .arg("package").required().help("Package name (or name@ver)") + .arg("version").help("Optional version (alternative to name@ver form)") + .action(dummy_action) + .subcommand("use") + .description("Switch tool version") + .option(Option("all").short_name('a').help("Show versions across all subos (default: current subos only)")) + .arg("target").required().help("Tool name (or name@ver one-shot)") + .arg("version").help("Version to switch to (omit to list installed versions)") + .action(dummy_action) + .subcommand("config") + .description("Show or modify xlings configuration") + .option(Option("lang").takes_value().value_name("LANG").help("Set language (en/zh)")) + .option(Option("mirror").takes_value().value_name("MIRROR").help("Set mirror (GLOBAL/CN)")) + .option(Option("add-xpkg").takes_value().value_name("FILE").help("Add xpkg file to package index")) + .option(Option("index-repo").takes_value().value_name("NS:URL").help("Add/update index repo (e.g. myns:https://...git)")) + .action(dummy_action); + app.subcommand(create_subos()); + + // Completions + app.subcommand("completions") + .description("Generate shell completions") + .option(Option("shell").takes_value().value_name("SHELL").help("Target shell [possible values: bash, fish, zsh]")) + .action([&app](const ParsedArgs& p) { + auto shell_opt = p.value("shell"); + if (!shell_opt) { + std::println(std::cerr, "SHELL not provided."); + return; + } + + auto shell = cmdline::shell_from_string(*shell_opt); + if (!shell || !cmdline::shell_supported(*shell)) { + std::println(std::cerr, "Unsupported shell: {}", *shell_opt); + return; + } + + std::print("{}", app.completions(*shell)); + }); + + return app.run(argc, argv); +} + +App create_subos() { + auto subos = App("subos") + .description("Manage sub-OS environments") + .subcommand("list") + .description("List all sub-OS environments") + .action(dummy_action) + .subcommand("ls") + .description("alias: list") + .action(dummy_action) + .subcommand("remove") + .description("Remove a sub-OS") + .arg("name").required().help("sub-OS name") + .action(dummy_action) + .subcommand("rm") + .description("alias: rm") + .arg("name").required().help("sub-OS name") + .action(dummy_action) + .subcommand("new") + .description("Create a new sub-OS") + .option(Option("storage").takes_value().value_name("mode").help("storage mode")) + .option(Option("image-size").takes_value().value_name("size").help("image size")) + .option(Option("from").takes_value().value_name("spec").help("from spec")) + .arg("name").required().help("sub-OS name") + .action(dummy_action) + .subcommand("use") + .description("Switch active sub-OS") + .option(Option("global").help("persist the choice into ~/.xlings.json + symlinki (legacy behavior; affects every shell)")) + .option(Option("shell").takes_value().value_name("kind").help("emit shell code on stdout for the user to eval/Invoke-Expression.")) + .option(Option("sandbox").help("Linux-only: enter via proot fs-isolation.")) + .arg("name").required().help("sub-OS name") + .action(dummy_action) + .subcommand("info") + .description("Show sub-OS details") + .arg("name").help("sub-OS name") + .action(dummy_action) + .subcommand("i") + .description("alias: info") + .arg("name").help("sub-OS name") + .action(dummy_action); + return subos; +} From 537446652d0440a973012b1e622fb17c110e9f62 Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 11:52:38 +0800 Subject: [PATCH 4/8] build: re-support xmake --- tests/xmake.lua | 2 ++ xmake.lua | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/xmake.lua b/tests/xmake.lua index 8634659..f226be2 100644 --- a/tests/xmake.lua +++ b/tests/xmake.lua @@ -13,4 +13,6 @@ target("cmdline_test") -- 强制整体链入,否则报 LNK1561: entry point must be defined。 if is_plat("windows") then add_ldflags("/wholearchive:gtest_main.lib", { force = true }) + else + add_links("gtest_main") end diff --git a/xmake.lua b/xmake.lua index 3d84752..5c75049 100644 --- a/xmake.lua +++ b/xmake.lua @@ -3,6 +3,7 @@ set_languages("c++23") target("cmdline") set_kind("static") add_files("src/*.cppm", { public = true, install = true }) + add_files("src/**/*.cppm", { public = true, install = true }) set_policy("build.c++.modules", true) if not is_host("macosx") then From 2f4a8e676dc924e228969c6cefee9d73ee89dcc0 Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 14:32:54 +0800 Subject: [PATCH 5/8] feat(completions): add bash support --- src/completions.cppm | 2 +- src/completions/bash.cppm | 380 +++++++++++++++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 3 deletions(-) diff --git a/src/completions.cppm b/src/completions.cppm index 3a42fed..962cb43 100644 --- a/src/completions.cppm +++ b/src/completions.cppm @@ -41,7 +41,7 @@ export [[nodiscard]] std::string_view to_string(Shell shell) { export [[nodiscard]] bool shell_supported(Shell shell) { switch (shell) { case Shell::fish: return true; - case Shell::bash: + case Shell::bash: return true; case Shell::zsh: return false; } return false; diff --git a/src/completions/bash.cppm b/src/completions/bash.cppm index 1aae080..fc59c0d 100644 --- a/src/completions/bash.cppm +++ b/src/completions/bash.cppm @@ -5,10 +5,386 @@ export module mcpplibs.cmdline:completions.bash; import std; import :completions; +namespace { + +/// Separator used to join subcommand names in the internal path representation +/// (e.g. `"myapp__subcmd__install__subcmd__config"`). +constexpr std::string_view path_sep = "__subcmd__"; + +/// Replace '-' with '__' so the result is a valid bash function name. +[[nodiscard]] std::string escape_name(std::string_view name) { + std::string result; + result.reserve(name.size()); + for (char c : name) result += (c == '-') ? "__" : std::string(1, c); + return result; +} + +/// Navigate the Command tree by `path_sep`-separated path components. +/// Returns a pointer to the target subcommand, or the root command when path +/// is empty. Returns `nullptr` when a component is not found (should not +/// happen with consistent input from `flatten_subcommands`). +[[nodiscard]] const mcpplibs::cmdline::completions::Command* walk_command( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view path) +{ + if (path.empty()) return &cmd; + + const auto* node = &cmd; + std::size_t start = 0; + while (start < path.size()) { + auto end = path.find(path_sep, start); + std::string_view component; + if (end == std::string_view::npos) { + component = path.substr(start); + start = path.size(); + } else { + component = path.substr(start, end - start); + start = end + path_sep.size(); + } + if (component.empty()) continue; + + bool found = false; + for (const auto& sub : node->subcommands) { + if (sub.name == component) { + node = ⊂ + found = true; + break; + } + } + if (!found) return nullptr; + } + return node; +} + +/// An entry in the flattened subcommand tree: the function-name path of the +/// parent, the subcommand's display name, and the subcommand's own +/// function-name path. +struct SubCmdEntry { + std::string parent_fn; + std::string name; + std::string fn; +}; + +void flatten_subcommands_impl( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view parent_fn, + std::vector& out) +{ + for (const auto& sub : cmd.subcommands) { + SubCmdEntry entry; + entry.parent_fn = std::string(parent_fn); + entry.name = sub.name; + entry.fn = std::string(parent_fn) + std::string(path_sep) + escape_name(sub.name); + out.push_back(entry); + flatten_subcommands_impl(sub, entry.fn, out); + } +} + +/// Flatten the subcommand tree into a list of `SubCmdEntry` tuples, one per +/// subcommand at every level. +[[nodiscard]] std::vector flatten_subcommands( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view parent_fn) +{ + std::vector out; + flatten_subcommands_impl(cmd, parent_fn, out); + return out; +} + +/// Check whether a subcommand's own options already include an option with +/// the given short or long name, for global re-offer dedup. +[[nodiscard]] bool has_option( + const mcpplibs::cmdline::completions::Command& node, + char short_, + std::string_view long_name) +{ + for (const auto& opt : node.options) { + if (!long_name.empty() && opt.long_name == long_name) return true; + if (short_ && opt.short_ == short_) return true; + } + return false; +} + +/// Append the short and/or long representation of one option to the word +/// list string. +void append_option(std::string& opts, const mcpplibs::cmdline::detail::Option& opt) { + if (opt.short_) { opts += " -"; opts += opt.short_; } + if (!opt.long_name.empty()) { opts += " --"; opts += opt.long_name; } +} + +/// Append root-level globals (deduped) to the word list. +void append_root_globals( + std::string& opts, + std::span root_globals, + const mcpplibs::cmdline::completions::Command& target) +{ + for (const auto& rg : root_globals) { + if (has_option(target, rg.short_, rg.long_name)) continue; + append_option(opts, rg); + } +} + +/// Build the space-separated word list for `compgen -W` at a given path. +/// Includes local options, subcommand names, and (when path is non-empty) +/// root-level globals. +[[nodiscard]] std::string all_options_for_path( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view path, + std::span root_globals) +{ + const auto* target = walk_command(cmd, path); + if (!target) return {}; + + std::string opts; + + for (const auto& opt : target->options) append_option(opts, opt); + + for (const auto& sub : target->subcommands) { + opts += ' '; + opts += sub.name; + } + + if (!path.empty()) append_root_globals(opts, root_globals, *target); + + if (!opts.empty() && opts[0] == ' ') opts.erase(0, 1); + return opts; +} + +/// Write one `case "$prev" in` pattern for a value-taking option. +/// Uses `compgen -f` for file completion, matching the stripped-down +/// feature set of our fish generator (no possible-values, no value-hints). +void append_option_detail( + std::string& out, + const mcpplibs::cmdline::detail::Option& opt) +{ + std::string patterns; + if (opt.short_) { patterns += '-'; patterns += opt.short_; } + if (!opt.long_name.empty()) { + if (!patterns.empty()) patterns += '|'; + patterns += "--"; + patterns += opt.long_name; + } + if (patterns.empty()) return; + + out += " "; + out += patterns; + out += ")\n"; + out += " COMPREPLY=($(compgen -f \"${cur}\"))\n"; + out += " return 0\n"; + out += " ;;\n"; +} + +/// Build the `case "$prev" in` block for value-taking options at a given +/// path. Includes root-level globals when path is non-empty. +[[nodiscard]] std::string option_details_for_path( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view path, + std::span root_globals) +{ + const auto* target = walk_command(cmd, path); + if (!target) return {}; + + std::string out; + + for (const auto& opt : target->options) { + if (opt.takes_value_) append_option_detail(out, opt); + } + + if (!path.empty()) { + for (const auto& rg : root_globals) { + if (!rg.takes_value_) continue; + if (has_option(*target, rg.short_, rg.long_name)) continue; + append_option_detail(out, rg); + } + } + + return out; +} + +/// Build the subcommand-detection cases for the prologue loop that walks +/// `COMP_WORDS` to determine which subcommand the user is in. +[[nodiscard]] std::string subcommand_detection_cases( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view fn) +{ + const auto entries = flatten_subcommands(cmd, fn); + if (entries.empty()) return {}; + + std::string out; + for (const auto& e : entries) { + out += " \""; + out += e.parent_fn; + out += ","; + out += e.name; + out += "\")\n"; + out += " cmd=\""; + out += e.fn; + out += "\"\n"; + out += " ;;\n"; + } + return out; +} + +/// Build the `case "${cmd}" in` dispatch branches for every unique +/// subcommand path. `fn` is the escaped root name, used as the parent +/// prefix for flattened paths so that the case labels match the values +/// set by the prologue loop. +/// Branches are deduplicated by `fn` so that future alias support does +/// not produce duplicate `case` labels. +[[nodiscard]] std::string subcommand_details( + const mcpplibs::cmdline::completions::Command& cmd, + std::span root_globals, + std::string_view fn) +{ + const auto entries = flatten_subcommands(cmd, fn); + + // Paths from flatten_subcommands include the root fn prefix + // (e.g. "myapp__subcmd__install"), but walk_command needs only the + // subcommand components (e.g. "install"). + const std::string root_prefix = std::string(fn) + std::string(path_sep); + + struct ScBranch { + std::string fn; + std::string path; // path_sep-separated relative to root + int depth; + }; + std::vector branches; + for (const auto& e : entries) { + std::string_view sc_path = e.fn; + if (sc_path.starts_with(root_prefix)) + sc_path.remove_prefix(root_prefix.size()); + + // Depth: number of __subcmd__ separators in the relative path + 1 + int depth = 1; + for (std::size_t pos = 0; + (pos = sc_path.find(path_sep, pos)) != std::string::npos; + pos += path_sep.size()) + ++depth; + branches.push_back({e.fn, std::string(sc_path), depth}); + } + + if (branches.empty()) return {}; + + // Sort and dedup by fn so that the output never produces duplicate + // `case` labels even if future alias expansion creates identical paths. + std::sort(branches.begin(), branches.end(), + [](const ScBranch& a, const ScBranch& b) { return a.fn < b.fn; }); + branches.erase( + std::unique(branches.begin(), branches.end(), + [](const ScBranch& a, const ScBranch& b) { return a.fn == b.fn; }), + branches.end()); + + std::string out; + for (const auto& br : branches) { + auto opts = all_options_for_path(cmd, br.path, root_globals); + auto opts_details = option_details_for_path(cmd, br.path, root_globals); + + // COMP_CWORD level = depth of this subcommand + 1 (the binary name itself) + int level = br.depth + 1; + + out += " "; + out += br.fn; + out += ")\n"; + out += " opts=\""; + out += opts; + out += "\"\n"; + out += " if [[ ${cur} == -* || ${COMP_CWORD} -eq "; + out += std::to_string(level); + out += " ]] ; then\n"; + out += " COMPREPLY=($(compgen -W \"${opts}\" -- \"${cur}\"))\n"; + out += " return 0\n"; + out += " fi\n"; + out += " case \"${prev}\" in\n"; + out += opts_details; + out += " *)\n"; + out += " COMPREPLY=()\n"; + out += " ;;\n"; + out += " esac\n"; + out += " COMPREPLY=($(compgen -W \"${opts}\" -- \"${cur}\"))\n"; + out += " return 0\n"; + out += " ;;\n"; + } + return out; +} + +} // unnamed namespace + namespace mcpplibs::cmdline::completions::bash { -export void generate(const completions::Command& /*cmd*/, std::ostream& /*out*/) { - // TODO +/// Generate a bash completion script for the given command tree. +/// +/// The output is a single `_{name}()` function registered with `complete -F` +/// that uses `compgen -W` for word-list matching and a prologue `case` loop +/// to detect which subcommand the user is currently typing. +export void generate(const Command& cmd, std::ostream& out) +{ + auto fn = escape_name(cmd.name); + auto name_opts = all_options_for_path(cmd, "", {}); + auto name_opts_details = option_details_for_path(cmd, "", {}); + auto subcmds_cases = subcommand_detection_cases(cmd, fn); + + // Pre-collect root-level global options so subcommand helpers can re-offer + // them (same pattern as fish.cppm's `gen_inner` global re-offer block). + std::vector root_globals; + for (const auto& opt : cmd.options) { + if (opt.global_) root_globals.emplace_back(opt); + } + auto subcmd_detail = subcommand_details(cmd, root_globals, fn); + + // The bash completion function follows clap_complete's layout: a prologue + // that sets up `cur`/`prev`/`cmd`, a loop to detect the current + // subcommand path, and a `case "${cmd}" in` switch with one branch per + // valid path (root + each subcommand). + + out << "_" << fn << "() {\n"; + out << " local i cur prev opts cmd\n"; + out << " COMPREPLY=()\n"; + out << " if [[ \"${BASH_VERSINFO[0]}\" -ge 4 ]]; then\n"; + out << " cur=\"$2\"\n"; + out << " else\n"; + out << " cur=\"${COMP_WORDS[COMP_CWORD]}\"\n"; + out << " fi\n"; + out << " prev=\"$3\"\n"; + out << " cmd=\"\"\n"; + out << " opts=\"\"\n"; + out << "\n"; + out << " for i in \"${COMP_WORDS[@]:0:COMP_CWORD}\"\n"; + out << " do\n"; + out << " case \"${cmd},${i}\" in\n"; + out << " \"," << cmd.name << "\")\n"; + out << " cmd=\"" << fn << "\"\n"; + out << " ;;\n"; + out << subcmds_cases; + out << " *)\n"; + out << " ;;\n"; + out << " esac\n"; + out << " done\n"; + out << "\n"; + out << " case \"${cmd}\" in\n"; + out << " " << fn << ")\n"; + out << " opts=\"" << name_opts << "\"\n"; + out << " if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then\n"; + out << " COMPREPLY=($(compgen -W \"${opts}\" -- \"${cur}\"))\n"; + out << " return 0\n"; + out << " fi\n"; + out << " case \"${prev}\" in\n"; + out << name_opts_details; + out << " *)\n"; + out << " COMPREPLY=()\n"; + out << " ;;\n"; + out << " esac\n"; + out << " COMPREPLY=($(compgen -W \"${opts}\" -- \"${cur}\"))\n"; + out << " return 0\n"; + out << " ;;\n"; + out << subcmd_detail; + out << " esac\n"; + out << "}\n"; + out << "\n"; + out << "if [[ \"${BASH_VERSINFO[0]}\" -eq 4 && \"${BASH_VERSINFO[1]}\" -ge 4 || \"${BASH_VERSINFO[0]}\" -gt 4 ]]; then\n"; + out << " complete -F _" << fn << " -o nosort -o bashdefault -o default " << cmd.name << "\n"; + out << "else\n"; + out << " complete -F _" << fn << " -o bashdefault -o default " << cmd.name << "\n"; + out << "fi\n"; } }; // namespace mcpplibs::cmdline::completions::bash From e1ca3b2a19ee72df1f8b28409e635e671a5b3d42 Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 16:07:37 +0800 Subject: [PATCH 6/8] feat(completions): add zsh support --- src/completions.cppm | 2 +- src/completions/zsh.cppm | 487 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 486 insertions(+), 3 deletions(-) diff --git a/src/completions.cppm b/src/completions.cppm index 962cb43..a147ef6 100644 --- a/src/completions.cppm +++ b/src/completions.cppm @@ -42,7 +42,7 @@ export [[nodiscard]] bool shell_supported(Shell shell) { switch (shell) { case Shell::fish: return true; case Shell::bash: return true; - case Shell::zsh: return false; + case Shell::zsh: return true; } return false; } diff --git a/src/completions/zsh.cppm b/src/completions/zsh.cppm index 9fe32bf..00bf426 100644 --- a/src/completions/zsh.cppm +++ b/src/completions/zsh.cppm @@ -5,10 +5,493 @@ export module mcpplibs.cmdline:completions.zsh; import std; import :completions; +namespace { + +/// Separator used to join subcommand names in the internal path representation +/// (e.g. `"myapp__subcmd__install__subcmd__config"`). +constexpr std::string_view path_sep = "__subcmd__"; + +/// Replace '-' with '_' so the result is a valid zsh function name. +[[nodiscard]] std::string escape_name(std::string_view name) { + std::string result; + result.reserve(name.size()); + for (char c : name) result += (c == '-') ? '_' : c; + return result; +} + +/// Escape help string for use inside `'spec[help]'` in `_arguments`. +/// Escapes `\`, `'`, `[`, `]`, `:`, `$`, `` ` ``, and replaces `\n` with space. +[[nodiscard]] std::string escape_help(std::string_view s) { + std::string result; + result.reserve(s.size()); + for (char c : s) { + switch (c) { + case '\\': result += "\\\\"; break; + case '\'': result += "'\\''"; break; + case '[': result += "\\["; break; + case ']': result += "\\]"; break; + case ':': result += "\\:"; break; + case '$': result += "\\$"; break; + case '`': result += "\\`"; break; + case '\n': result += ' '; break; + default: result += c; break; + } + } + return result; +} + +/// Escape value string for use inside `'spec:value:action'` in `_arguments`. +/// Same as `escape_help` plus escape `(`, `)`, and space. +[[nodiscard]] std::string escape_value(std::string_view s) { + std::string result; + result.reserve(s.size()); + for (char c : s) { + switch (c) { + case '\\': result += "\\\\"; break; + case '\'': result += "'\\''"; break; + case '[': result += "\\["; break; + case ']': result += "\\]"; break; + case ':': result += "\\:"; break; + case '$': result += "\\$"; break; + case '`': result += "\\`"; break; + case '(': result += "\\("; break; + case ')': result += "\\)"; break; + case ' ': result += "\\ "; break; + case '\n': result += ' '; break; + default: result += c; break; + } + } + return result; +} + +/// Navigate the Command tree by `path_sep`-separated path components. +/// Returns a pointer to the target subcommand, or the root command when path +/// is empty. Returns `nullptr` when a component is not found (should not +/// happen with consistent input from `flatten_subcommands`). +[[nodiscard]] const mcpplibs::cmdline::completions::Command* walk_command( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view path) +{ + if (path.empty()) return &cmd; + + const auto* node = &cmd; + std::size_t start = 0; + while (start < path.size()) { + auto end = path.find(path_sep, start); + std::string_view component; + if (end == std::string_view::npos) { + component = path.substr(start); + start = path.size(); + } else { + component = path.substr(start, end - start); + start = end + path_sep.size(); + } + if (component.empty()) continue; + + bool found = false; + for (const auto& sub : node->subcommands) { + if (sub.name == component) { + node = ⊂ + found = true; + break; + } + } + if (!found) return nullptr; + } + return node; +} + +/// An entry in the flattened subcommand tree: the function-name path of the +/// parent, the subcommand's display name, and the subcommand's own +/// function-name path (using escaped component names for valid zsh identifiers). +struct SubCmdEntry { + std::string parent_fn; + std::string name; + std::string fn; +}; + +void flatten_subcommands_impl( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view parent_fn, + std::vector& out) +{ + for (const auto& sub : cmd.subcommands) { + SubCmdEntry entry; + entry.parent_fn = std::string(parent_fn); + entry.name = sub.name; + entry.fn = std::string(parent_fn) + std::string(path_sep) + escape_name(sub.name); + out.push_back(entry); + flatten_subcommands_impl(sub, entry.fn, out); + } +} + +/// Flatten the subcommand tree into a list of `SubCmdEntry` tuples, one per +/// subcommand at every level. +[[nodiscard]] std::vector flatten_subcommands( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view parent_fn) +{ + std::vector out; + flatten_subcommands_impl(cmd, parent_fn, out); + return out; +} + +/// Check whether a subcommand's own options already include an option with +/// the given short or long name, for global re-offer dedup. +[[nodiscard]] bool has_option( + const mcpplibs::cmdline::completions::Command& node, + char short_, + std::string_view long_name) +{ + for (const auto& opt : node.options) { + if (!long_name.empty() && opt.long_name == long_name) return true; + if (short_ && opt.short_ == short_) return true; + } + return false; +} + +/// Build `_arguments` spec lines for all options of the given node. +/// Root globals are re-offered when `is_root` is false (i.e., for subcommand +/// nodes), deduped against the node's own options. +[[nodiscard]] std::string write_options( + const mcpplibs::cmdline::completions::Command& cmd, + bool is_root, + std::span root_globals) +{ + std::vector opts; + for (const auto& opt : cmd.options) opts.emplace_back(&opt); + + if (!is_root) { + for (const auto& rg : root_globals) { + if (has_option(cmd, rg.short_, rg.long_name)) continue; + opts.emplace_back(&rg); + } + } + + std::string out; + for (const auto* opt : opts) { + auto help = opt->help_.empty() ? "" : escape_help(opt->help_); + auto value_name = opt->value_name_.empty() ? " " : opt->value_name_; + + if (opt->short_) { + out += " '"; + if (opt->takes_value_) { + out += '-'; + out += opt->short_; + out += '+'; + if (!help.empty()) out += '[' + help + ']'; + out += ':'; + out += value_name; + out += ":_default"; + } else { + out += '-'; + out += opt->short_; + if (!help.empty()) out += '[' + help + ']'; + } + out += "' \\\n"; + } + + if (!opt->long_name.empty()) { + out += " '"; + if (opt->takes_value_) { + out += "--"; + out += opt->long_name; + out += '='; + if (!help.empty()) out += '[' + help + ']'; + out += ':'; + out += value_name; + out += ":_default"; + } else { + out += "--"; + out += opt->long_name; + if (!help.empty()) out += '[' + help + ']'; + } + out += "' \\\n"; + } + } + return out; +} + +/// Build `_arguments` spec lines for positionals. +[[nodiscard]] std::string write_positionals(const mcpplibs::cmdline::completions::Command& cmd) { + std::string out; + for (const auto& arg : cmd.args) { + auto help = arg.help_.empty() ? "" : escape_help(" -- " + arg.help_); + // Required → single colon prefix; optional → double colon prefix + auto cardinality = arg.required_ ? "" : ":"; + out += " '"; + out += cardinality; + out += ':'; + out += arg.name; + out += help; + out += ":_default' \\\n"; + } + return out; +} + +/// List direct subcommands in zsh's `commands` array format: +/// `'name:description'`. +/// One line per subcommand (no aliases). +[[nodiscard]] std::string subcommands_of(const mcpplibs::cmdline::completions::Command& cmd) { + if (cmd.subcommands.empty()) return {}; + + std::string out; + for (const auto& sub : cmd.subcommands) { + auto desc = sub.description.empty() ? "" : escape_help(sub.description); + out += " '"; + out += sub.name; + out += ':'; + out += desc; + out += "' \\\n"; + } + return out; +} + +/// Build the `_arguments ... && ret=0` block for a node. +/// `escaped_fn` is the escaped `path_sep`-joined name used for the +/// `_commands` helper-function reference (empty for root means no ref). +[[nodiscard]] std::string get_args_of( + const mcpplibs::cmdline::completions::Command& cmd, + bool is_root, + std::span root_globals, + std::string_view escaped_fn) +{ + auto opts = write_options(cmd, is_root, root_globals); + auto positionals = write_positionals(cmd); + + std::string out; + out += " _arguments \"${_arguments_options[@]}\" : \\\n"; + out += opts; + out += positionals; + + if (cmd.has_subcommands()) { + out += " \":: :_"; + out += escaped_fn; + out += "_commands\" \\\n"; + out += " \"*::: :->"; + out += cmd.name; + out += "\" \\\n"; + } + + out += " && ret=0"; + return out; +} + +/// Build the `case $state in` dispatch block for subcommands of `cmd`. +/// `fn` is the RAW `path_sep`-joined path from root (used for tree +/// navigation via `walk_command`). +/// `escaped_fn` is the ESCAPED path (used for zsh function names). +/// Recursively generates nested state cases for deeper subcommand levels. +[[nodiscard]] std::string get_subcommands_of( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view fn, + std::string_view escaped_fn, + std::span root_globals) +{ + if (cmd.subcommands.empty()) return {}; + + // Build hyphenated name for curcontext: replace path_sep with '-' + std::string name_hyphen; + if (fn.empty()) { + name_hyphen = cmd.name; + } else { + name_hyphen = std::string(fn); + std::size_t pos = 0; + while ((pos = name_hyphen.find(path_sep, pos)) != std::string::npos) { + name_hyphen.replace(pos, path_sep.size(), "-"); + pos += 1; + } + name_hyphen += '-'; + name_hyphen += cmd.name; + } + + std::string all_subcommands; + for (const auto& sub : cmd.subcommands) { + // Raw path for tree navigation + auto sub_fn = std::string(fn); + if (!sub_fn.empty()) sub_fn += std::string(path_sep); + sub_fn += sub.name; + + // Escaped path for zsh function names + auto sub_escaped_fn = std::string(escaped_fn); + if (!sub_escaped_fn.empty()) sub_escaped_fn += std::string(path_sep); + sub_escaped_fn += escape_name(sub.name); + + all_subcommands += " ("; + all_subcommands += sub.name; + all_subcommands += ")\n"; + + auto args = get_args_of(sub, false, root_globals, sub_escaped_fn); + if (!args.empty()) { + all_subcommands += args; + all_subcommands += "\n"; + } + + auto children = get_subcommands_of(sub, sub_fn, sub_escaped_fn, root_globals); + if (!children.empty()) { + all_subcommands += children; + } + + all_subcommands += " ;;\n"; + } + + auto pos = cmd.args.size() + 1; + + std::string out; + out += "\n case $state in\n"; + out += " ("; + out += cmd.name; + out += ")\n"; + out += " words=($line["; + out += std::to_string(pos); + out += "] \"${words[@]}\")\n"; + out += " (( CURRENT += 1 ))\n"; + out += " curcontext=\"${curcontext%:*:*}:"; + out += name_hyphen; + out += "-command-$line["; + out += std::to_string(pos); + out += "]:\"\n"; + out += " case $line["; + out += std::to_string(pos); + out += "] in\n"; + out += all_subcommands; + out += " esac\n"; + out += " ;;\n"; + out += " esac"; + return out; +} + +/// Generate all `_{escaped_path}_commands()` helper functions. +/// One function per unique fn path (deduped by escaped path). +/// Root's own helper is included (from `flatten_subcommands(cmd, fn)`). +[[nodiscard]] std::string subcommand_details( + const mcpplibs::cmdline::completions::Command& cmd, + std::string_view fn) +{ + std::string out; + + // Root's own commands function (only if root has subcommands) + if (cmd.has_subcommands()) { + auto root_cmds = subcommands_of(cmd); + if (!root_cmds.empty()) { + out += "\n(( $+functions[_"; + out += fn; + out += "_commands] )) ||\n_"; + out += fn; + out += "_commands() {\n"; + out += " local commands; commands=(\n"; + out += root_cmds; + out += " )\n"; + out += " _describe -t commands '"; + out += cmd.name; + out += " commands' commands \"$@\"\n"; + out += "}\n"; + } + } + + // Subcommands at every level. `flatten_subcommands` uses escaped + // component names (callers pass fn=escape_name(root.name)), so + // entry fn paths are already valid zsh identifiers. We strip the + // root prefix before calling `walk_command` (which uses raw names). + auto entries = flatten_subcommands(cmd, fn); + const std::string root_prefix = std::string(fn) + std::string(path_sep); + + // Dedup by fn path + std::sort(entries.begin(), entries.end(), + [](const SubCmdEntry& a, const SubCmdEntry& b) { return a.fn < b.fn; }); + entries.erase( + std::unique(entries.begin(), entries.end(), + [](const SubCmdEntry& a, const SubCmdEntry& b) { return a.fn == b.fn; }), + entries.end()); + + for (const auto& e : entries) { + // Strip root prefix for tree navigation + std::string_view sc_path = e.fn; + if (sc_path.starts_with(root_prefix)) + sc_path.remove_prefix(root_prefix.size()); + + auto target = walk_command(cmd, sc_path); + if (!target || target->subcommands.empty()) continue; + + auto cmds = subcommands_of(*target); + if (cmds.empty()) continue; + + out += "\n(( $+functions[_"; + out += e.fn; + out += "_commands] )) ||\n_"; + out += e.fn; + out += "_commands() {\n"; + out += " local commands; commands=(\n"; + out += cmds; + out += " )\n"; + out += " _describe -t commands '"; + out += cmd.name; + out += " "; + // Human-readable name: replace __subcmd__ with spaces + { + std::string human = e.fn; + std::size_t pos = 0; + while ((pos = human.find(path_sep, pos)) != std::string::npos) { + human.replace(pos, path_sep.size(), " "); + pos += 1; + } + out += human; + } + out += " commands' commands \"$@\"\n"; + out += "}\n"; + } + + return out; +} + +} // unnamed namespace + namespace mcpplibs::cmdline::completions::zsh { -export void generate(const completions::Command& /*cmd*/, std::ostream& /*out*/) { - // TODO +/// Generate a zsh completion script for the given command tree. +/// +/// The output is a `#compdef` script containing a `_{name}()` function with +/// `_arguments` blocks and `case $state` dispatch, plus `_commands()` +/// helper functions for subcommand listing. +export void generate(const Command& cmd, std::ostream& out) +{ + auto fn = escape_name(cmd.name); + + // Pre-collect root-level global options for re-offer under subcommands. + std::vector root_globals; + for (const auto& opt : cmd.options) { + if (opt.global_) root_globals.emplace_back(opt); + } + + auto initial_args = get_args_of(cmd, true, root_globals, fn); + auto subcmds = get_subcommands_of(cmd, "", fn, root_globals); + auto subcmd_dets = subcommand_details(cmd, fn); + + out << "#compdef " << cmd.name << "\n"; + out << "\n"; + out << "autoload -U is-at-least\n"; + out << "\n"; + out << "_" << fn << "() {\n"; + out << " typeset -A opt_args\n"; + out << " typeset -a _arguments_options\n"; + out << " local ret=1\n"; + out << "\n"; + out << " if is-at-least 5.2; then\n"; + out << " _arguments_options=(-s -S -C)\n"; + out << " else\n"; + out << " _arguments_options=(-s -C)\n"; + out << " fi\n"; + out << "\n"; + out << " local context curcontext=\"$curcontext\" state line\n"; + out << initial_args << "\n"; + out << subcmds << "\n"; + out << "}\n"; + out << subcmd_dets << "\n"; + out << "if [ \"$funcstack[1]\" = \"_" << fn << "\" ]; then\n"; + out << " _" << fn << " \"$@\"\n"; + out << "else\n"; + out << " compdef _" << fn << " " << cmd.name << "\n"; + out << "fi\n"; } }; // namespace mcpplibs::cmdline::completions::zsh From 0a93dd9ae806519dc30f83c5adec04b939a109e2 Mon Sep 17 00:00:00 2001 From: Johan Xie Date: Sat, 11 Jul 2026 16:16:12 +0800 Subject: [PATCH 7/8] docs: update api.md with shell completions --- docs/api.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/api.md b/docs/api.md index 33b2433..3a2d1e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,6 +11,8 @@ | `OptionValue` | 单个选项取值(flag 计数 + values) | | `ParseError` | 解析错误(kind + message) | | `Argv` | `std::vector` 的别名,用于 `parse_from` | +| `Shell` | 目标 shell 枚举:`bash` / `fish` / `zsh` | +| `Command` | 命令树快照,用于生成补全脚本 | 解析返回 `ParseResult`,即 `std::expected`。`-h`/`--help`、`--version` 时,`ParseError::kind` 分别为 `help` / `version`,不携带 `message`,通过 `is_error()` 可区分真实错误。 @@ -40,6 +42,14 @@ --- +## Shell + +| 函数 | 说明 | +|------|------| +| `shell_from_string(sv)` | 字符串 → `std::optional`(如 `"fish"` → `Shell::fish`) | +| `to_string(shell)` | `Shell` → `std::string_view` | +| `shell_supported(shell)` | 该 shell 补全是否已实现(fish / bash / zsh) | + ## ParseError | 成员 | 说明 | @@ -168,3 +178,66 @@ app.option(cmdline::Option("yes").long_opt("yes").global().help("Auto confirm")) app.arg(cmdline::Arg("input").required().help("Input file")); app.subcommand(cmdline::App("add").description("Add").arg(cmdline::Arg("x").required()).action([](const cmdline::ParsedArgs&) {})); ``` + +--- + +## Shell 补全 + +### 生成流程 + +1. 从 `App` 构建 `completions::Command` 树(`snapshot(app)` 或自动由 `generate_completions` 调用) +2. `Command` 包含名称、描述、版本、`Arg`/`Option` 列表、子命令列表 +3. 选择目标 `Shell` 后调用 `generate_completions`,输出补全脚本文本 + +### completions::Command + +| 成员 | 说明 | +|------|------| +| `name` | 命令名 | +| `description` | 描述文本 | +| `version` | 版本号 | +| `args` / `options` / `subcommands` | `vector` / `vector