diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index ac180b1c3..9d3b20c2c 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -52,6 +52,16 @@ jobs: run: | "$MCPP_FRESH" test + - name: Shell completion in bash, zsh, PowerShell and fish + env: + MCPP_REQUIRE_SHELLS: "1" + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y zsh fish + command -v pwsh + MCPP="$MCPP_FRESH" bash tests/e2e/894_shell_completion.sh + # Each package under `modules/` carries its own tests, and they are built # as that package ALONE -- a configuration the root suite never produces, # since there every module is linked together. A subsystem that has diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index e4f7d0457..6de528b88 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -41,6 +41,12 @@ jobs: export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") "$MCPP_SELF" test + - name: Native PowerShell completion + shell: pwsh + run: | + $env:MCPP = $env:MCPP_FRESH + python tests/scripts/test_shell_completion.py ShellCompletionTests.test_powershell ShellCompletionTests.test_query_is_read_only_and_does_not_execute_partial_commands + # WS8: this row has Visual Studio, so its answer is the "Windows with # usable MSVC" row of docs/01 and docs/20. - name: The documented default toolchain is this host's answer diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 1459d5511..d0ff40939 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -73,6 +73,48 @@ mcpp --version > rather than using `source`, and verify the active command with > `Get-Command mcpp.exe`. +### Shell completion + +mcpp provides Tab completion for bash, zsh, PowerShell (`pwsh`) and fish. +Generate all four scripts, or select one shell: + +```bash +mcpp self completion +mcpp self completion bash +``` + +Scripts are written to `/config/shell/`. This command does not +initialize a toolchain or access the network. Normal sandbox initialization +also creates the scripts. Repeating the command updates generated scripts; +keep local customizations in the shell's own configuration file. + +For the default `~/.mcpp` home, add the corresponding line to the configuration +file below and start a new shell, or execute the line in the current session: + +| Shell | Configuration file | Loading command | +|---|---|---| +| bash | `~/.bashrc` | `source ~/.mcpp/config/shell/mcpp.bash` | +| zsh | `${ZDOTDIR:-$HOME}/.zshrc` | `source ~/.mcpp/config/shell/mcpp.zsh` | +| fish | `$XDG_CONFIG_HOME/fish/config.fish`, or `~/.config/fish/config.fish` | `source ~/.mcpp/config/shell/mcpp.fish` | +| PowerShell | `$PROFILE.CurrentUserAllHosts` | `. "$HOME/.mcpp/config/shell/mcpp.ps1"` | + +Use the actual mcpp home for a standalone custom prefix or an explicit +`MCPP_HOME`. zsh initializes its completion system if it is not already active; +when the configuration calls `compinit`, load the mcpp script after that call. +In PowerShell, create the profile's parent directory and file if absent. + +The standalone installer generates the scripts and appends a loading command +for the shell identified by `SHELL`. Reinstallation does not append the same +command twice. `MCPP_NO_COMPLETION=1` disables this profile change; +`MCPP_NO_PATH=1` independently disables its PATH change. Unknown shells receive +manual loading instructions. + +Completion covers commands, nested subcommands, long and short options, and +built-in values such as cache modes, profiles and mirrors. File arguments use +the shell's filename completion. Completion queries do not run project scripts, +install dependencies or access registries. Package names, custom profiles, +target names and registry versions are not dynamically queried. + ## Creating a Project ```bash @@ -267,4 +309,3 @@ For the differences between the four modes and their artifact layouts, see [10 - Explaining default decisions: `mcpp why [toolchain|runtime|deps]`; host capability checkup: `mcpp self doctor`; machine-readable resolution manifest: the build artifact `target///resolution.json`. - Offline operation: `mcpp --offline` or `MCPP_OFFLINE=1` prevents index refreshes, downloads, and toolchain installation. In a home that has never been used it also skips the first-use sandbox bootstrap (index clone, ninja, patchelf), announces the skip once, and leaves the home un-bootstrapped; commands that need those tools report it. - diff --git a/docs/zh/01-getting-started.md b/docs/zh/01-getting-started.md index 4749fe8fa..7d388bf14 100644 --- a/docs/zh/01-getting-started.md +++ b/docs/zh/01-getting-started.md @@ -70,6 +70,43 @@ mcpp --version > 命令安装,重启 PowerShell 而不是执行 `source`,并用 > `Get-Command mcpp.exe` 确认当前生效的命令。 +### Shell 补全 + +mcpp 为 bash、zsh、PowerShell(`pwsh`)和 fish 提供 Tab 补全。 +以下命令生成全部四种脚本,或仅生成指定 shell 的脚本: + +```bash +mcpp self completion +mcpp self completion bash +``` + +脚本写入 `/config/shell/`。该命令不初始化工具链,也不访问网络。 +正常的沙箱初始化也会生成这些脚本。重复执行会更新生成的脚本;本地自定义配置 +放在 shell 自身的配置文件中。 + +使用默认 `~/.mcpp` home 时,将对应加载语句加入下表中的配置文件并启动新 shell, +或在当前会话直接执行该语句: + +| Shell | 配置文件 | 加载语句 | +|---|---|---| +| bash | `~/.bashrc` | `source ~/.mcpp/config/shell/mcpp.bash` | +| zsh | `${ZDOTDIR:-$HOME}/.zshrc` | `source ~/.mcpp/config/shell/mcpp.zsh` | +| fish | `$XDG_CONFIG_HOME/fish/config.fish`,未设置时为 `~/.config/fish/config.fish` | `source ~/.mcpp/config/shell/mcpp.fish` | +| PowerShell | `$PROFILE.CurrentUserAllHosts` | `. "$HOME/.mcpp/config/shell/mcpp.ps1"` | + +独立安装的自定义前缀或显式设置的 `MCPP_HOME` 使用实际的 mcpp home 路径。 +zsh 的补全系统尚未启用时,脚本会初始化它;配置中已有 `compinit` 调用时, +在该调用之后加载 mcpp 脚本。PowerShell 的 profile 文件及其父目录不存在时, +先创建它们。 + +独立安装器会生成脚本,并为 `SHELL` 标识的 shell 追加加载语句。重复安装不会 +重复追加同一条语句。`MCPP_NO_COMPLETION=1` 关闭这一配置文件修改; +`MCPP_NO_PATH=1` 独立控制 PATH 修改。未识别的 shell 会收到手动加载提示。 + +补全覆盖命令、嵌套子命令、长短选项,以及缓存模式、构建 profile、镜像等内置值。 +文件参数使用 shell 原生文件名补全。补全查询不执行项目脚本、不安装依赖,也不访问 +索引。包名、自定义 profile、目标名称和索引中的版本不进行动态查询。 + ## 创建项目 ```bash diff --git a/install.sh b/install.sh index dc472327e..a558adebd 100755 --- a/install.sh +++ b/install.sh @@ -8,9 +8,11 @@ # MCPP_VERSION — pin a specific version (default: latest) # MCPP_PREFIX — install root (default: $HOME/.mcpp) # MCPP_NO_PATH — set to skip shell-rc PATH editing +# MCPP_NO_COMPLETION — set to skip shell-rc completion editing # # Layout afterwards (PREFIX = $HOME/.mcpp by default): # $PREFIX/bin/{mcpp,xlings} ← binary + pinned bundled xlings +# $PREFIX/config/shell/ ← sourceable shell completion scripts # $PREFIX/registry/ ← seeded on first `mcpp` invocation # $PREFIX/... ← mcpp's full self-contained tree # @@ -21,6 +23,8 @@ set -euo pipefail REPO="mcpp-community/mcpp" VERSION="${MCPP_VERSION:-latest}" PREFIX="${MCPP_PREFIX:-$HOME/.mcpp}" +shell_name="${SHELL:-}" +shell_name="${shell_name##*/}" # ---- platform detection --------------------------------------------------- # OS+arch → release asset platform tag. Linux reports the arch as aarch64; @@ -126,22 +130,46 @@ mkdir -p "$PREFIX" echo ":: Extracting to $PREFIX" tar -xzf "$WORK/mcpp.tar.gz" -C "$PREFIX" --strip-components=1 +# Older pinned releases predate completion. Probe help without initialization. +completion_available=0 +if "$PREFIX/bin/mcpp" self --help 2>/dev/null | grep 'completion' >/dev/null; then + MCPP_HOME="$PREFIX" "$PREFIX/bin/mcpp" self completion + completion_available=1 +fi + # ---- PATH integration ----------------------------------------------------- -if [[ -z "${MCPP_NO_PATH:-}" ]]; then - rc="" - case "${SHELL##*/}" in - bash) rc="$HOME/.bashrc" ;; - zsh) rc="${ZDOTDIR:-$HOME}/.zshrc" ;; - fish) rc="$HOME/.config/fish/config.fish" ;; +rc="" +case "$shell_name" in + bash) rc="$HOME/.bashrc" ;; + zsh) rc="${ZDOTDIR:-$HOME}/.zshrc" ;; + fish) rc="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;; + pwsh) + if command -v pwsh >/dev/null 2>&1; then + rc=$(pwsh -NoProfile -Command '$PROFILE.CurrentUserAllHosts') + fi ;; +esac +shell_quote() { + local value="$1" + case "$shell_name" in + pwsh) value="${value//\'/\'\'}" ;; + fish) + value="${value//\\/\\\\}" + value="${value//\'/\\\'}" ;; + *) value="${value//\'/\'\\\'\'}" ;; esac + printf "'%s'" "$value" +} +if [[ -z "${MCPP_NO_PATH:-}" ]]; then if [[ -n "$rc" ]]; then - if [[ "${SHELL##*/}" == "fish" ]]; then - line="set -gx PATH \"$PREFIX/bin\" \$PATH" - else - line="export PATH=\"$PREFIX/bin:\$PATH\"" - fi + quoted_bin=$(shell_quote "$PREFIX/bin") + case "$shell_name" in + fish) line="set -gx PATH $quoted_bin \$PATH" ;; + pwsh) line="\$env:PATH = $quoted_bin + [IO.Path]::PathSeparator + \$env:PATH" ;; + *) line="export PATH=$quoted_bin:\"\$PATH\"" ;; + esac mkdir -p "$(dirname "$rc")" - if ! grep -Fqs "$PREFIX/bin" "$rc" 2>/dev/null; then + if ! grep -Fqx "$line" "$rc" 2>/dev/null \ + && ! grep -Fqs "$PREFIX/bin" "$rc" 2>/dev/null; then printf '\n# mcpp\n%s\n' "$line" >> "$rc" echo ":: Added $PREFIX/bin to PATH via $rc" else @@ -153,13 +181,39 @@ if [[ -z "${MCPP_NO_PATH:-}" ]]; then fi fi +# ---- completion integration ---------------------------------------------- +if [[ -z "${MCPP_NO_COMPLETION:-}" && "$completion_available" == 1 ]]; then + if [[ -n "$rc" ]]; then + extension="$shell_name" + [[ "$shell_name" == pwsh ]] && extension=ps1 + script="$PREFIX/config/shell/mcpp.$extension" + quoted_script=$(shell_quote "$script") + if [[ "$shell_name" == pwsh ]]; then + line="if (Test-Path -LiteralPath $quoted_script) { . $quoted_script }" + elif [[ "$shell_name" == fish ]]; then + line="test -f $quoted_script; and source $quoted_script" + else + line="[ ! -f $quoted_script ] || source $quoted_script" + fi + mkdir -p "$(dirname "$rc")" + if ! grep -Fqx "$line" "$rc" 2>/dev/null; then + printf '\n# mcpp completion\n%s\n' "$line" >> "$rc" + echo ":: Enabled completion via $rc" + fi + else + echo ":: Completion scripts: $PREFIX/config/shell/ (source the script for your shell)" + fi +fi + # ---- verify install ------------------------------------------------------- echo "$PREFIX/bin/mcpp" --version echo echo "✓ mcpp installed at $PREFIX" -if [[ -n "${rc:-}" ]]; then - echo " Open a new shell (or 'source $rc') and run: mcpp --help" +if [[ -n "${rc:-}" && -f "$rc" ]]; then + load_command="source $(shell_quote "$rc")" + [[ "$shell_name" == pwsh ]] && load_command=". $(shell_quote "$rc")" + echo " Open a new shell (or $load_command) and run: mcpp --help" else echo " Add $PREFIX/bin to your PATH, then run: mcpp --help" fi diff --git a/src/cli.cppm b/src/cli.cppm index a7e3bbd7a..dfcd7c295 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -27,6 +27,7 @@ import mcpp.cli.cmd_xpkg; import mcpp.cli.cmd_registry; import mcpp.cli.cmd_self; import mcpp.cli.cmd_toolchain; +import mcpp.cli.completion; import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.wire; @@ -85,6 +86,7 @@ void print_usage() { std::println(" mcpp self env Print mcpp paths and toolchain"); std::println(" mcpp self config [--mirror CN|GLOBAL] Show or modify mcpp's xlings config"); std::println(" mcpp self version Show mcpp version"); + std::println(" mcpp self completion [shell] Install bash/zsh/pwsh/fish completion scripts"); std::println(" mcpp self explain Show extended description for an error code"); std::println(" mcpp --help / --version Help / version"); std::println(""); @@ -150,6 +152,7 @@ struct ReportUnnarrowablePaths { int run(int argc, char** argv) { namespace cl = mcpplibs::cmdline; ReportUnnarrowablePaths reportUnnarrowable_; + const bool completion_query = argc >= 2 && std::string_view(argv[1]) == "__complete"; // ─── --quiet / --no-color: pre-scan ───────────────────────────────── // The cmdline lib propagates global options into nested subcommand @@ -158,7 +161,7 @@ int run(int argc, char** argv) { // "Resolving toolchain" banner) honours the user's intent. This is // a side-channel only — the global options are still declared on // the App below so they show up in --help and pass schema checks. - for (int i = 1; i < argc; ++i) { + for (int i = 1; !completion_query && i < argc; ++i) { std::string_view a = argv[i]; // Everything after a bare `--` belongs to the program being run or the // test binary being invoked, not to mcpp. Without this, `mcpp run -- -j 4` @@ -330,7 +333,7 @@ int run(int argc, char** argv) { char** trimmed_argp = trimmed_argv.empty() ? nullptr : trimmed_argv.data(); // ─── Build the top-level App ──────────────────────────────────────── - auto app = cl::App("mcpp") + auto app = completion::App("mcpp") .version(std::string{mcpp::toolchain::MCPP_VERSION}) .description("modern C++ build tool") .option(cl::Option("quiet").short_name('q') @@ -363,7 +366,7 @@ int run(int argc, char** argv) { .help("Print the machine-output protocol this build speaks (JSON)")) // ─── project commands ────────────────────────────────────────── - .subcommand(cl::App("new") + .subcommand(completion::App("new") .description("Create a new mcpp package skeleton") // not .required(): `--list-templates` runs without a name // (cmd_new validates presence for project creation itself). @@ -373,7 +376,7 @@ int run(int argc, char** argv) { .option(cl::Option("list-templates").takes_value().value_name("PKG") .help("List templates from exact [ns.]pkg[@ver]")) .action(wrap_rc(cmd_new))) - .subcommand(cl::App("build") + .subcommand(completion::App("build") .description("Build the current package") .option(cl::Option("configure-only") .help("Generate compile_commands.json without compiling or linking")) @@ -414,7 +417,7 @@ int run(int argc, char** argv) { .option(cl::Option("play-game") .help("Play a game in the status row while it builds: --play-game=snake|stack|runner, or one at random")) .action(wrap_rc(cmd_build))) - .subcommand(cl::App("run") + .subcommand(completion::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") // Named `bin`, NOT `target`, and the rename is load-bearing. // @@ -513,7 +516,7 @@ int run(int argc, char** argv) { .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_run(p, std::span(passthrough)); }))) - .subcommand(cl::App("test") + .subcommand(completion::App("test") .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") .help("Run only tests whose name contains PATTERN (optional)")) @@ -562,7 +565,7 @@ int run(int argc, char** argv) { .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_test(p, std::span(passthrough)); }))) - .subcommand(cl::App("clean") + .subcommand(completion::App("clean") .description("Remove target/, or with --stale only the fingerprint directories under it that no recorded build still uses") .option(cl::Option("bmi-cache").help("Also wipe the global build cache (see `mcpp cache clean`)")) .option(cl::Option("stale").help("Only remove target/// directories that no recorded build considers current")) @@ -570,7 +573,7 @@ int run(int argc, char** argv) { .option(cl::Option("older-than").takes_value().value_name("DURATION") .help("Keep unrecorded directories written more recently than this, e.g. 12h, 3d (default 1d; 0 keeps none; implies --stale)")) .action(wrap_rc(cmd_clean))) - .subcommand(cl::App("why") + .subcommand(completion::App("why") .description("Explain how the toolchain / runtime / deps / runners / sources were resolved") .arg(cl::Arg("topic").help("toolchain | runtime | deps | runners | sources | tool | payload (default: all)")) .arg(cl::Arg("subject").help("for `tool` and `payload`: the name to look up")) @@ -587,11 +590,11 @@ int run(int argc, char** argv) { .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/50-machine-output.md)")) .action(wrap_rc(cmd_why))) - .subcommand(cl::App("resolve") + .subcommand(completion::App("resolve") .description("Re-resolve the build plan and explain it") .option(cl::Option("explain").help("Print resolved toolchain / runtime / deps / runners")) .action(wrap_rc(cmd_why))) - .subcommand(cl::App("add") + .subcommand(completion::App("add") .description("Add a dependency to mcpp.toml") .arg(cl::Arg("pkg").help( "Exact package spec, e.g. foo@1.0.0 or compat.gtest@1.15.2") @@ -599,26 +602,26 @@ int run(int argc, char** argv) { .option(cl::Option("dev").help( "Add to [dev-dependencies] (test-only, e.g. compat.gtest)")) .action(wrap_rc(mcpp::pm::commands::cmd_add))) - .subcommand(cl::App("remove") + .subcommand(completion::App("remove") .description("Remove a dependency from mcpp.toml") .arg(cl::Arg("pkg").help("Exact package selector [ns.]name").required()) .action(wrap_rc(mcpp::pm::commands::cmd_remove))) - .subcommand(cl::App("update") + .subcommand(completion::App("update") .description("Re-resolve dependencies and rewrite mcpp.lock") .arg(cl::Arg("pkg").help("If given, update only that package")) .action(wrap_rc(mcpp::pm::commands::cmd_update))) - .subcommand(cl::App("search") + .subcommand(completion::App("search") .description("Search packages in configured registries") .arg(cl::Arg("keyword").help("Search keyword (substring match)").required()) .option(cl::Option("all-versions") .help("List every published version instead of the latest few")) .action(wrap_rc(cmd_search))) - .subcommand(cl::App("publish") + .subcommand(completion::App("publish") .description("Publish package to default registry") .option(cl::Option("dry-run").help("Print xpkg.lua without uploading")) .option(cl::Option("allow-dirty").help("Allow uncommitted changes")) .action(wrap_rc(cmd_publish))) - .subcommand(cl::App("pack") + .subcommand(completion::App("pack") // "archive", not "tarball": a Windows target produces a .zip, and // the help said tarball while the code had already stopped // agreeing. `--format tar` likewise selects "an archive rather @@ -681,9 +684,9 @@ int run(int argc, char** argv) { .action(wrap_rc(cmd_pack))) // ─── emit (one nested subcommand: xpkg) ──────────────────────── - .subcommand(cl::App("emit") + .subcommand(completion::App("emit") .description("Generate a document describing this project (xpkg, sbom, build-database)") - .subcommand(cl::App("xpkg") + .subcommand(completion::App("xpkg") .description("Generate xpkg Lua entry") .option(cl::Option("version").short_name('V').takes_value().value_name("VER") .help("Override package version")) @@ -697,14 +700,14 @@ int run(int argc, char** argv) { // already means "generate a document describing this project" and // already carries `-o`. A separate `mcpp sbom` would be a second // spelling of an abstraction that exists. - .subcommand(cl::App("sbom") + .subcommand(completion::App("sbom") .description("Write a CycloneDX bill of materials for the recorded resolution") .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") .help("Write to file instead of stdout"))) // The plan as an S1 build database, planned as `build // --configure-only` plans it and written into nothing; the build // selectors are `build`'s own (docs/specs/build-database.md). - .subcommand(cl::App("build-database") + .subcommand(completion::App("build-database") .description("Print the build plan as an S1 build database, without writing into the project") .option(cl::Option("spec").takes_value().value_name("NAME") .help("Document specification: s1 (default; the S1 IDE profile of P2977R2) | compile-commands")) @@ -745,9 +748,9 @@ int run(int argc, char** argv) { }))) // ─── xpkg (descriptor tooling: parse) ────────────────────────── - .subcommand(cl::App("xpkg") + .subcommand(completion::App("xpkg") .description("Inspect / validate xpkg descriptors") - .subcommand(cl::App("parse") + .subcommand(completion::App("parse") .description("Parse a descriptor's mcpp segment exactly as the resolver would (strict: unknown keys are errors)") .option(cl::Option("json") .help("Emit machine-readable JSON (legacy payload, kept for ever)")) @@ -768,12 +771,12 @@ int run(int argc, char** argv) { }))) // ─── resource management ─────────────────────────────────────── - .subcommand(cl::App("toolchain") + .subcommand(completion::App("toolchain") .description("Install / list / select / remove C++ toolchains") - .subcommand(cl::App("list").description("List installed toolchains") + .subcommand(completion::App("list").description("List installed toolchains") .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/50-machine-output.md)"))) - .subcommand(cl::App("install") + .subcommand(completion::App("install") .description("Install a toolchain via mcpp's xlings") // Both `mcpp toolchain install gcc 16.1.0` and `mcpp toolchain // install gcc@16.1.0` are accepted, and the version may be @@ -785,7 +788,7 @@ int run(int argc, char** argv) { .arg(cl::Arg("version").help("e.g. 16.1.0, 15, 15.1")) .option(cl::Option("target").takes_value().help( "Install the toolchain payload for (e.g. x86_64-windows-gnu)"))) - .subcommand(cl::App("default") + .subcommand(completion::App("default") .description("Set the default toolchain (and optionally the default target)") // Same dual-form as `install`: `gcc@16.1.0` or `gcc 16.1.0`, // partial versions allowed. @@ -793,43 +796,43 @@ int run(int argc, char** argv) { .arg(cl::Arg("version").help("(optional, alternative to @-form)")) .option(cl::Option("target").takes_value().help( "Default build target (omit = host)"))) - .subcommand(cl::App("remove") + .subcommand(completion::App("remove") .description("Uninstall a toolchain") .arg(cl::Arg("spec").help("@").required()) .option(cl::Option("target").takes_value().help( "Remove the payload for instead of the host one"))) .action(wrap_rc(cmd_toolchain))) - .subcommand(cl::App("cache") + .subcommand(completion::App("cache") .description("Inspect and manage the global build cache") - .subcommand(cl::App("dir") + .subcommand(completion::App("dir") .description("Print the cache root (and any pre-v1 cache)")) - .subcommand(cl::App("list") + .subcommand(completion::App("list") .description("List cache entries with size + last-use") .option(cl::Option("json") .help("Emit machine-readable JSON (legacy payload, kept for ever)")) .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/50-machine-output.md)"))) - .subcommand(cl::App("info") + .subcommand(completion::App("info") .description("Show details (incl. key inputs) for a cached package") .arg(cl::Arg("pkg").help("@").required())) - .subcommand(cl::App("prune") + .subcommand(completion::App("prune") .description("Drop entries not used within a threshold") .option(cl::Option("older-than").takes_value().value_name("N{s|m|h|d}") .help("Age threshold (e.g. 30d)"))) - .subcommand(cl::App("gc") + .subcommand(completion::App("gc") .description("LRU-collect package entries to a size and/or age budget") .option(cl::Option("max-size").takes_value().value_name("N{MiB|GiB}") .help("Keep the package cache under this size (e.g. 5GiB)")) .option(cl::Option("older-than").takes_value().value_name("N{s|m|h|d}") .help("Also drop entries unused for longer than this"))) - .subcommand(cl::App("clean") + .subcommand(completion::App("clean") .description("Drop cache entries (default: package entries only)") .option(cl::Option("deps").help("Drop package entries (default)")) .option(cl::Option("std").help("Drop std module entries")) .option(cl::Option("all").help("Drop both")) .option(cl::Option("legacy") .help("Remove the unused pre-v1 cache at $MCPP_HOME/bmi"))) - .subcommand(cl::App("verify") + .subcommand(completion::App("verify") .description("Check every entry's manifest against the files on disk")) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("cache", p, { @@ -842,18 +845,18 @@ int run(int argc, char** argv) { {"verify", cmd_cache_verify}, }); }))) - .subcommand(cl::App("index") + .subcommand(completion::App("index") .description("Manage configured package registries") - .subcommand(cl::App("list") + .subcommand(completion::App("list") .description("List configured registries")) - .subcommand(cl::App("add") + .subcommand(completion::App("add") .description("Add a custom registry") .arg(cl::Arg("name").help("Registry name").required()) .arg(cl::Arg("url").help("Registry URL").required())) - .subcommand(cl::App("remove") + .subcommand(completion::App("remove") .description("Remove a registry") .arg(cl::Arg("name").help("Registry name").required())) - .subcommand(cl::App("update") + .subcommand(completion::App("update") // #540: the argument selects among the PROJECT's custom // indices only. The global repos are always synced wholesale, // because `xlings update` has no per-index mode to call — a @@ -866,13 +869,13 @@ int run(int argc, char** argv) { .arg(cl::Arg("name").help( "Update only this PROJECT-level custom index " "(the global repos sync regardless)"))) - .subcommand(cl::App("status") + .subcommand(completion::App("status") .description("Show local index presence/freshness (offline)")) - .subcommand(cl::App("pin") + .subcommand(completion::App("pin") .description("Pin a custom index to a commit rev in mcpp.toml") .arg(cl::Arg("name").help("Index name").required()) .arg(cl::Arg("rev").help("Commit sha (defaults to current lock rev)"))) - .subcommand(cl::App("unpin") + .subcommand(completion::App("unpin") .description("Remove rev pin from a custom index in mcpp.toml") .arg(cl::Arg("name").help("Index name").required())) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { @@ -888,25 +891,28 @@ int run(int argc, char** argv) { }))) // ─── about mcpp itself ───────────────────────────────────────── - .subcommand(cl::App("self") + .subcommand(completion::App("self") .description("Inspect and manage mcpp itself") - .subcommand(cl::App("init") + .subcommand(completion::App("init") .description("Initialize or repair mcpp sandbox") .option(cl::Option("force") .help("Delete registry and re-initialize from scratch"))) - .subcommand(cl::App("doctor") + .subcommand(completion::App("doctor") .description("Diagnose mcpp environment health")) - .subcommand(cl::App("env") + .subcommand(completion::App("env") .description("Print mcpp paths and configuration") .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/50-machine-output.md)"))) - .subcommand(cl::App("config") + .subcommand(completion::App("config") .description("Show or modify mcpp's private xlings configuration") .option(cl::Option("mirror").takes_value().value_name("CN|GLOBAL") .help("Set xlings mirror for mcpp's private registry"))) - .subcommand(cl::App("version") + .subcommand(completion::App("version") .description("Show mcpp version")) - .subcommand(cl::App("explain") + .subcommand(completion::App("completion") + .description("Write shell completion scripts under mcpp home/config/shell") + .arg(cl::Arg("shell").help("bash, zsh, pwsh or fish; omitted writes all four"))) + .subcommand(completion::App("explain") .description("Show extended description for an error code") .arg(cl::Arg("code").help("Error code such as E0001").required())) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { @@ -916,6 +922,7 @@ int run(int argc, char** argv) { {"env", cmd_env}, {"config", cmd_self_config}, {"version", cmd_self_version}, + {"completion", completion::install_command}, {"explain", cmd_explain_action}, }); }))) @@ -923,7 +930,7 @@ int run(int argc, char** argv) { // ─── top-level explain alias ────────────────────────────────── // Preserves `mcpp explain E0001` as a shortcut for // `mcpp self explain E0001`. - .subcommand(cl::App("explain") + .subcommand(completion::App("explain") .description("Show extended description for an error code") .arg(cl::Arg("code").help("Error code such as E0001").required()) .action(wrap_rc(cmd_explain_action))) @@ -933,12 +940,12 @@ int run(int argc, char** argv) { // `mcpp help` is intercepted by the pre-scan above (prints the // canonical usage screen). `mcpp version` is wired through the // App so it shows up in the auto-generated subcommand list. - .subcommand(cl::App("version") + .subcommand(completion::App("version") .description("Show mcpp version") .action(wrap_rc(cmd_self_version))) // ─── hidden / internal ───────────────────────────────────────── - .subcommand(cl::App("dyndep") + .subcommand(completion::App("dyndep") .description("(internal: invoked by ninja) Emit ninja dyndep file from .ddi inputs") .option(cl::Option("output").short_name('o').takes_value().value_name("PATH") .help("Path to write dyndep file")) @@ -964,7 +971,7 @@ int run(int argc, char** argv) { // the argument shape below is a contract: `stage --verify content // --output ` copies one file, creates the destination's // parent, and writes only when the bytes differ. - .subcommand(cl::App("stage") + .subcommand(completion::App("stage") .description("Copy one file into place: create the destination's parent, write only when the content differs (invoked by build.ninja and by ${mcpp.self} actions)") .option(cl::Option("output").short_name('o').takes_value().value_name("PATH") .help("Destination path; its parent directory is created")) @@ -973,7 +980,7 @@ int run(int argc, char** argv) { .option(cl::Option("list").takes_value().value_name("FILE") .help("Place every `\\t` pair of FILE in one process (#734 E4); several lines naming one destination are its sources")) .action(wrap_rc(cmd_stage))) - .subcommand(cl::App("place-dlls") + .subcommand(completion::App("place-dlls") .description("(internal: invoked by ninja) Place beside a Windows program the DLLs it imports from its runtime search directories") .option(cl::Option("output").takes_value().value_name("PATH").help("the stamp to write")) .option(cl::Option("depfile").takes_value().value_name("PATH").help("the depfile naming every DLL placed")) @@ -982,7 +989,7 @@ int run(int argc, char** argv) { .option(cl::Option("toolset-crt").takes_value().value_name("DIR") .help("the selected toolset's C++ runtime directory")) .action(wrap_rc(cmd_place_dlls))) - .subcommand(cl::App("coff-def") + .subcommand(completion::App("coff-def") .description("(internal: invoked by ninja) Discover exports in COFF and LLVM bitcode objects") .option(cl::Option("output").takes_value().value_name("PATH").help("the .def to write")) .option(cl::Option("name").takes_value().value_name("DLL").help("LIBRARY name recorded in the .def")) @@ -992,12 +999,12 @@ int run(int argc, char** argv) { .option(cl::Option("exports-file").takes_value().value_name("PATH").help("the target's `exports` patterns, one per line")) .option(cl::Option("required").help("a consumer of this build links the DLL: an empty export surface is an error")) .action(wrap_rc(cmd_coff_def))) - .subcommand(cl::App("bmi-equal") + .subcommand(completion::App("bmi-equal") .description("(internal: invoked by ninja) Compare two BMIs ignoring the compiler's embedded timestamp") .action(wrap_rc(cmd_bmi_equal))) // The three edges of the detach-codegen schedule. Internal, and named as // such: they are only ever invoked by a generated build.ninja. - .subcommand(cl::App("bmi-compile") + .subcommand(completion::App("bmi-compile") .description("(internal) Compile a module interface and return when its BMI is published") .option(cl::Option("bmi").takes_value().value_name("PATH").help("BMI this unit publishes")) .option(cl::Option("slot").takes_value().value_name("PATH").help("where .log/.rc are kept")) @@ -1008,19 +1015,21 @@ int run(int argc, char** argv) { .option(cl::Option("dep-from").takes_value().value_name("PATH").help("scanner depfile to adopt")) .option(cl::Option("dep-to").takes_value().value_name("PATH").help("where ninja expects this edge's depfile")) .action(wrap_rc(cmd_bmi_compile))) - .subcommand(cl::App("bmi-supervise") + .subcommand(completion::App("bmi-supervise") .description("(internal) Run a compiler to completion and record its status") .option(cl::Option("slot").takes_value().value_name("PATH")) .option(cl::Option("token").takes_value().value_name("PATH")) .option(cl::Option("command-file").takes_value().value_name("PATH")) .action(wrap_rc(cmd_bmi_supervise))) - .subcommand(cl::App("bmi-await") + .subcommand(completion::App("bmi-await") .description("(internal) Join a detached compiler and replay its diagnostics") .option(cl::Option("slot").takes_value().value_name("PATH")) .option(cl::Option("object").takes_value().value_name("PATH")) .action(wrap_rc(cmd_bmi_await))) ; + if (completion_query) return completion::query(app.command, argc, argv); + // The bareword `mcpp help` and `mcpp` (no args) both print the // canonical help screen. if (argc <= 1) { diff --git a/src/cli/completion.cppm b/src/cli/completion.cppm new file mode 100644 index 000000000..9ec308001 --- /dev/null +++ b/src/cli/completion.cppm @@ -0,0 +1,175 @@ +module; +#include + +export module mcpp.cli.completion; + +import std; +import mcpplibs.cmdline; +import mcpp.home; +import mcpp.shell; + +export namespace mcpp::cli::completion { + +struct Command { + std::string name; + std::string description; + std::vector options; + std::vector children; +}; + +// cmdline 0.0.1 has no introspection API. Record metadata at the same point +// that the parser receives it, rather than maintaining a second command list. +class App : public mcpplibs::cmdline::App { + using Base = mcpplibs::cmdline::App; +public: + Command command; + explicit App(std::string_view name) : Base(name), command{std::string(name), {}, {}, {}} {} + App& version(std::string_view v) { (void)Base::version(v); return *this; } + App& description(std::string_view v) { + command.description = v; + (void)Base::description(v); + return *this; + } + App& arg(mcpplibs::cmdline::Arg v) { (void)Base::arg(std::move(v)); return *this; } + App& option(mcpplibs::cmdline::Option v) { + command.options.push_back(v); + (void)Base::option(std::move(v)); + return *this; + } + App& subcommand(App v) { + command.children.push_back(std::move(v.command)); + (void)Base::subcommand(std::move(v)); + return *this; + } + template App& action(Fn&& fn) { + (void)Base::action(std::forward(fn)); + return *this; + } +}; + +struct Result { + std::vector words; + bool files = false; + std::string file_prefix; +}; + +std::vector values(const Command& command, const mcpplibs::cmdline::Option& option) { + const auto& name = option.long_name; + if (name == "cache") return {"global", "local", "off"}; + if (name == "profile") return {"dev", "release"}; + if (name == "mirror") return {"CN", "GLOBAL"}; + if (name == "message-format") return {"human", "json"}; + if (name == "format") { + if (command.name == "pack") return {"tar", "dir"}; + return {"json"}; + } + if (name == "spec" && command.name == "build-database") return {"s1", "compile-commands"}; + return {}; +} + +bool file_value(const mcpplibs::cmdline::Option& option) { + return option.value_name_ == "PATH" || option.value_name_ == "FILE" + || option.value_name_ == "DIR"; +} + +// words excludes the executable and includes the current (possibly empty) +// word last. No parsing or command action is executed for partial input. +Result candidates(const Command& root, std::span words) { + if (words.empty()) return {}; + const Command* current = &root; + std::vector globals; + for (const auto& option : root.options) + if (option.global_) globals.push_back(&option); + auto find_option = [&](std::string_view token) -> const mcpplibs::cmdline::Option* { + for (const auto& option : current->options) + if (option.matches(token)) return &option; + for (const auto* option : globals) + if (option->matches(token)) return option; + return nullptr; + }; + const mcpplibs::cmdline::Option* pending = nullptr; + bool positional = false; + for (std::size_t i = 0; i + 1 < words.size(); ++i) { + std::string_view word = words[i]; + if (pending) { pending = nullptr; continue; } + if (word == "--") return {{}, true}; + if (word.starts_with('-')) { + auto eq = word.find('='); + const auto* option = find_option(word.substr(0, eq)); + if (!option && word.size() > 2 && !word.starts_with("--")) + option = find_option(word.substr(0, 2)); + if (option && option->takes_value_ && eq == std::string_view::npos + && (word.starts_with("--") || word.size() == 2)) pending = option; + continue; + } + auto child = std::ranges::find(current->children, word, &Command::name); + if (!positional && child != current->children.end()) current = &*child; + else positional = true; + } + std::string_view prefix = words.back(); + std::string attached; + if (!pending && prefix.starts_with('-')) { + const auto eq = prefix.find('='); + if (eq != std::string_view::npos) { + pending = find_option(prefix.substr(0, eq)); + attached = prefix.substr(0, eq + 1); + prefix.remove_prefix(eq + 1); + } else if (prefix.size() > 2 && !prefix.starts_with("--")) { + const auto* option = find_option(prefix.substr(0, 2)); + if (option && option->takes_value_) { + pending = option; + attached = prefix.substr(0, 2); + prefix.remove_prefix(2); + } + } + } + Result result; + auto add = [&](std::string word) { + if (word.starts_with(prefix)) result.words.push_back(attached + word); + }; + if (pending) { + for (auto word : values(*current, *pending)) add(std::move(word)); + result.files = file_value(*pending); + if (result.files) result.file_prefix = attached; + } else if (prefix.starts_with('-')) { + add("--help"); add("-h"); + add("--version"); add("-V"); + auto add_option = [&](const auto& option) { + if (!option.long_name.empty()) add("--" + option.long_name); + if (option.short_) add(std::string("-") + option.short_); + }; + for (const auto& option : current->options) add_option(option); + for (const auto* option : globals) add_option(*option); + } else if (current->name == "completion" && !positional) { + for (const auto* shell : {"bash", "zsh", "pwsh", "fish"}) add(shell); + } else if (!positional && !current->children.empty()) { + for (const auto& child : current->children) + if (!child.description.starts_with("(internal")) add(child.name); + } else result.files = true; + std::ranges::sort(result.words); + auto tail = std::ranges::unique(result.words); + result.words.erase(tail.begin(), tail.end()); + return result; +} + +// The first output line directs the adapter; subsequent lines are literal +// candidates. Shell adapters never evaluate output as shell code. +int query(const Command& root, int argc, char** argv) { + std::vector words; + for (int i = 2; i < argc; ++i) words.emplace_back(argv[i]); + // PowerShell before 7.3 drops empty native arguments. Its adapter sends + // the current word with a marker so an empty prefix survives argv. + if (!words.empty() && words.back().starts_with("__mcpp_word__")) + words.back().erase(0, std::string_view("__mcpp_word__").size()); + auto result = candidates(root, words); + std::println("{}", result.files ? "files" : "words"); + if (result.files && !result.file_prefix.empty()) std::println("{}", result.file_prefix); + for (const auto& word : result.words) std::println("{}", word); + return 0; +} + +int install_command(const mcpplibs::cmdline::ParsedArgs& parsed) { + return mcpp::shell::install(mcpp::home::root(), parsed.positional(0)); +} + +} // namespace mcpp::cli::completion diff --git a/src/config.cppm b/src/config.cppm index f3f6dc395..c3b209155 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -22,6 +22,7 @@ export module mcpp.config; import std; import mcpp.home; +import mcpp.shell; import mcpp.modgraph.glob; // try_narrow: the one UTF-8 spelling of a path import mcpp.libs.toml; import mcpp.libs.json; @@ -743,6 +744,9 @@ std::expected load_or_init( std::format("cannot create '{}': {}", d.string(), ec.message())}); } + if (mcpp::shell::install(cfg.mcppHome) != 0) + return std::unexpected(ConfigError{"cannot install shell completion scripts"}); + // 2b. Initialize logger (early init with defaults; re-init after config load) { mcpp::log::Config logCfg; diff --git a/src/shell.cppm b/src/shell.cppm new file mode 100644 index 000000000..78bbb7bcc --- /dev/null +++ b/src/shell.cppm @@ -0,0 +1,153 @@ +module; +#include + +export module mcpp.shell; + +import std; + +export namespace mcpp::shell { + +std::string_view script(std::string_view shell) { + if (shell == "bash") return R"MCPP(# mcpp completion; source this file from ~/.bashrc. +_mcpp_complete() { + local item mode prefix="" cur="${COMP_WORDS[COMP_CWORD]}" + local -a words=() reply=() + local i + # Bash splits '=' and ':' by default; keep them inside mcpp arguments. + for ((i=1; i<=COMP_CWORD; i++)); do + if [[ ${COMP_WORDS[i]} == '=' || ${COMP_WORDS[i]} == ':' ]]; then + if ((${#words[@]})); then + words[${#words[@]}-1]+="${COMP_WORDS[i]}" + fi + elif ((i>1)) && [[ ${COMP_WORDS[i-1]} == '=' || ${COMP_WORDS[i-1]} == ':' ]]; then + words[${#words[@]}-1]+="${COMP_WORDS[i]}" + else + words+=("${COMP_WORDS[i]}") + fi + done + while IFS= read -r item; do reply+=("$item"); done < <(command "${COMP_WORDS[0]}" __complete "${words[@]}" 2>/dev/null) + mode=${reply[0]:-words} + COMPREPLY=() + if [[ $mode == files ]]; then + prefix=${reply[1]:-} + if [[ -n $prefix && $cur == "$prefix"* ]]; then + cur=${cur#"$prefix"} + else + prefix="" + fi + while IFS= read -r item; do COMPREPLY+=("$prefix$item"); done < <(compgen -f -- "$cur") + compopt -o filenames 2>/dev/null || true + else + for item in "${reply[@]:1}"; do + # Readline replaces only the portion after its last word break. + if [[ $item == *=* && $cur != *=* ]]; then item=${item#*=}; fi + COMPREPLY+=("$item") + done + fi +} +complete -F _mcpp_complete mcpp mcpp.exe +)MCPP"; + if (shell == "zsh") return R"MCPP(# mcpp completion; source this file after compinit in ~/.zshrc. +if (( ! $+functions[compdef] )); then + autoload -Uz compinit + compinit +fi +_mcpp_complete() { + local -a reply + reply=("${(@f)$(command "$words[1]" __complete "${words[@]:1:$((CURRENT-1))}" 2>/dev/null)}") + if [[ $reply[1] == files ]]; then + if [[ -n $reply[2] ]]; then compset -P "${(b)reply[2]}"; fi + _files + else + compadd -- "${reply[@]:1}" + fi +} +compdef _mcpp_complete mcpp mcpp.exe +)MCPP"; + if (shell == "fish") return R"MCPP(# mcpp completion; source this file from config.fish. +function __mcpp_candidates + # --tokenize (-o) exists in fish 3.x; --tokens-expanded (-x) is 4.0+. + set -l words (commandline -opc) + set -l current (commandline -ct) + set -l reply (command $words[1] __complete $words[2..-1] "__mcpp_word__$current" 2>/dev/null) + if test "$reply[1]" = files + set -l prefix "$reply[2]" + if test -n "$prefix" + set current (string sub -s (math (string length -- "$prefix") + 1) -- "$current") + end + for path in (__fish_complete_path "$current") + printf '%s%s\n' "$prefix" "$path" + end + else + printf '%s\n' $reply[2..-1] + end +end +complete -c mcpp -e +complete -c mcpp.exe -e +complete -c mcpp -f -a '(__mcpp_candidates)' +complete -c mcpp.exe -f -a '(__mcpp_candidates)' +)MCPP"; + if (shell == "pwsh") return R"MCPP(# mcpp completion; dot-source this file from $PROFILE. +Register-ArgumentCompleter -Native -CommandName mcpp,mcpp.exe -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $tokens = @() + $elements = @($commandAst.CommandElements) + foreach ($element in $elements | Select-Object -Skip 1) { + if ($element.Extent.EndOffset -ge $cursorPosition) { break } + if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $tokens += $element.Value + } else { + $tokens += $element.Extent.Text + } + } + $reply = @(& $elements[0].Value __complete @tokens "__mcpp_word__$wordToComplete" 2>$null) + if ($reply.Count -eq 0) { return } + if ($reply[0] -eq 'files') { + $prefix = if ($reply.Count -gt 1) { $reply[1] } else { '' } + $path = $wordToComplete.Substring($prefix.Length) + foreach ($match in [System.Management.Automation.CompletionCompleters]::CompleteFilename($path)) { + [System.Management.Automation.CompletionResult]::new( + $prefix + $match.CompletionText, $match.ListItemText, $match.ResultType, $match.ToolTip) + } + return + } + foreach ($candidate in $reply | Select-Object -Skip 1) { + [System.Management.Automation.CompletionResult]::new($candidate, $candidate, 'ParameterValue', $candidate) + } +} +)MCPP"; + return {}; +} + +int install(const std::filesystem::path& home, std::string_view shell = {}) { + if (!shell.empty() && script(shell).empty()) { + std::println(stderr, "error: unsupported shell '{}'; expected bash, zsh, pwsh or fish", shell); + return 2; + } + const auto directory = home / "config" / "shell"; + std::error_code ec; + std::filesystem::create_directories(directory, ec); + if (ec) { + std::println(stderr, "error: cannot create shell config directory: {}", ec.message()); + return 1; + } + for (const auto* name : {"bash", "zsh", "pwsh", "fish"}) { + if (!shell.empty() && shell != name) continue; + const auto path = directory / (std::string("mcpp.") + (std::string_view(name) == "pwsh" ? "ps1" : name)); + { + std::ifstream in(path, std::ios::binary); + std::string existing(std::istreambuf_iterator{in}, {}); + if (existing == script(name)) continue; + } + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << script(name); + out.close(); + if (!out) { + std::println(stderr, "error: cannot write shell completion script"); + return 1; + } + } + return 0; +} + +} // namespace mcpp::shell diff --git a/tests/e2e/894_shell_completion.sh b/tests/e2e/894_shell_completion.sh new file mode 100644 index 000000000..ca803545b --- /dev/null +++ b/tests/e2e/894_shell_completion.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# requires: elf python3 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +python3 "$ROOT/tests/scripts/test_shell_completion.py" +echo "PASS: shell completion" diff --git a/tests/scripts/test_shell_completion.py b/tests/scripts/test_shell_completion.py new file mode 100644 index 000000000..ffebbd983 --- /dev/null +++ b/tests/scripts/test_shell_completion.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Exercise completion through the fresh binary and actual shell adapters.""" + +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tarfile +import tempfile +import unittest + + +def native_path(value): + """Make a path exported by Git Bash usable by native Windows Python.""" + if os.name == "nt" and value[:1] == "/" and value[2:3] == "/": + return value[1].upper() + ":" + value[2:] + return value + + +ROOT = Path(__file__).resolve().parents[2] +MCPP = Path(native_path(os.environ["MCPP"])).resolve() if os.environ.get("MCPP") else None + + +@unittest.skipUnless(MCPP, "set MCPP to a freshly built binary") +class ShellCompletionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="mcpp-completion-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root / "home with spaces" + self.env = dict(os.environ, MCPP_HOME=str(self.home), + XDG_CACHE_HOME=str(self.root / "cache"), + XDG_CONFIG_HOME=str(self.root / "config"), + XDG_DATA_HOME=str(self.root / "data")) + self.env.pop("MCPP_VERBOSE", None) + self.run_command([str(MCPP), "self", "completion"]) + self.scripts = self.home / "config" / "shell" + self.bin = self.root / "bin" + self.bin.mkdir() + shutil.copy2(MCPP, self.bin / ("mcpp.exe" if os.name == "nt" else "mcpp")) + self.env["PATH"] = str(self.bin) + os.pathsep + self.env["PATH"] + + def run_command(self, argv, **kwargs): + result = subprocess.run(argv, env=self.env, cwd=self.root, text=True, + capture_output=True, timeout=30, **kwargs) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout.splitlines() + + def shell(self, name): + path = shutil.which(name) + if path is None: + if os.environ.get("MCPP_REQUIRE_SHELLS"): + self.fail(f"required shell missing: {name}") + self.skipTest(f"shell not installed: {name}") + return path + + def test_query_is_read_only_and_does_not_execute_partial_commands(self): + self.env["MCPP_HOME"] = str(self.root / "absent") + self.assertEqual(self.run_command([str(MCPP), "__complete", "self", "co"]), + ["words", "completion", "config"]) + self.assertEqual(self.run_command([str(MCPP), "__complete", "build", "--cache=l"]), + ["words", "--cache=local"]) + self.assertEqual(self.run_command([str(MCPP), "__complete", "build", "--jobs", ""]), + ["words"]) + self.assertEqual(self.run_command([str(MCPP), "__complete", "run", "--", "--cache"]), + ["files"]) + self.assertFalse((self.root / "absent").exists()) + + def test_install_is_idempotent_and_rejects_unknown_shell(self): + files = sorted(p.name for p in self.scripts.iterdir()) + self.assertEqual(files, ["mcpp.bash", "mcpp.fish", "mcpp.ps1", "mcpp.zsh"]) + before = {p.name: p.stat().st_mtime_ns for p in self.scripts.iterdir()} + self.run_command([str(MCPP), "self", "completion"]) + self.assertEqual(before, {p.name: p.stat().st_mtime_ns for p in self.scripts.iterdir()}) + result = subprocess.run([str(MCPP), "self", "completion", "bad"], env=self.env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertIn("unsupported shell", result.stderr) + + def test_bash(self): + code = r''' +source "$MCPP_HOME/config/shell/mcpp.bash" +source "$MCPP_HOME/config/shell/mcpp.bash" +COMP_WORDS=(mcpp self co); COMP_CWORD=2 +_mcpp_complete; printf '%s\n' "${COMPREPLY[@]}" +COMP_WORDS=(mcpp build --cache = l); COMP_CWORD=4 +_mcpp_complete; printf '%s\n' "${COMPREPLY[@]}" +COMP_WORDS=(mcpp build --cache=lo); COMP_CWORD=2 +_mcpp_complete; printf '%s\n' "${COMPREPLY[@]}" +COMP_WORDS=(mcpp emit xpkg -o 'some path/fi'); COMP_CWORD=4 +_mcpp_complete; printf '%s\n' "${COMPREPLY[@]}" +COMP_WORDS=(mcpp emit xpkg '--output=some path/fi'); COMP_CWORD=3 +_mcpp_complete; printf '%s\n' "${COMPREPLY[@]}" +''' + (self.root / "some path").mkdir() + (self.root / "some path" / "file.lua").touch() + self.assertEqual(self.run_command([self.shell("bash"), "--noprofile", "--norc", "-c", code]), + ["completion", "config", "local", "--cache=local", "some path/file.lua", + "--output=some path/file.lua"]) + + def test_zsh(self): + # Run the registered callback with the same words/CURRENT supplied by + # the completion widget, capturing compadd's literal arguments. + code = r''' +if [[ -n $MCPP_TEST_ZSH_ROOT ]]; then + module_path=("$MCPP_TEST_ZSH_ROOT"/usr/lib/*/zsh/*(N/)) +fi +compdef() { :; } +source "$MCPP_HOME/config/shell/mcpp.zsh" +source "$MCPP_HOME/config/shell/mcpp.zsh" +compadd() { shift; printf '%s\n' "$@"; } +_files() { printf 'files\n'; } +words=(mcpp self co ignored); CURRENT=3; _mcpp_complete +words=(mcpp build --cache=l); CURRENT=3; _mcpp_complete +words=(mcpp emit xpkg -o 'some path/fi'); CURRENT=5; _mcpp_complete +''' + self.assertEqual(self.run_command([self.shell("zsh"), "-f", "-c", code]), + ["completion", "config", "--cache=local", "files"]) + + def test_zsh_tab_widget(self): + import fcntl + import pty + import select + import termios + import time + + shell = self.shell("zsh") + (self.root / "artifact.lua").touch() + master, slave = pty.openpty() + + def child_setup(): + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + + env = dict(self.env, TERM="xterm", ZDOTDIR=str(self.root)) + process = subprocess.Popen([shell, "-f"], cwd=self.root, env=env, + stdin=slave, stdout=slave, stderr=slave, + preexec_fn=child_setup) + os.close(slave) + + def read_until(marker): + output = b"" + deadline = time.monotonic() + 15 + while marker not in output and time.monotonic() < deadline: + if select.select([master], [], [], 0.2)[0]: + output += os.read(master, 65536) + self.assertIn(marker, output, output.decode(errors="replace")) + + setup = r''' +if [[ -n $MCPP_TEST_ZSH_ROOT ]]; then + fpath=("$MCPP_TEST_ZSH_ROOT"/usr/share/zsh/functions/**/*(N/)) + module_path=("$MCPP_TEST_ZSH_ROOT"/usr/lib/*/zsh/*(N/)) +fi +mkdir -p "$XDG_CACHE_HOME" +autoload -Uz compinit +compinit -u -d "$XDG_CACHE_HOME/zcompdump" +source "$MCPP_HOME/config/shell/mcpp.zsh" +mcpp-inspect() { print -r -- "MCPP_BUFFER:$BUFFER"; BUFFER=''; zle reset-prompt; } +zle -N mcpp-inspect +bindkey '^X' mcpp-inspect +print MCPP_READY +''' + try: + os.write(master, setup.encode()) + read_until(b"\r\nMCPP_READY\r\n") + os.write(master, b"mcpp self conf\t\x18") + read_until(b"MCPP_BUFFER:mcpp self config") + os.write(master, b"mcpp emit xpkg --output=artifact\t\x18") + read_until(b"MCPP_BUFFER:mcpp emit xpkg --output=artifact.lua") + finally: + process.kill() + process.wait(timeout=5) + os.close(master) + + def test_fish(self): + code = r''' +if set -q MCPP_TEST_FISH_DATA_DIR + set -g fish_function_path "$MCPP_TEST_FISH_DATA_DIR/functions" $fish_function_path +end +source "$MCPP_HOME/config/shell/mcpp.fish" +source "$MCPP_HOME/config/shell/mcpp.fish" +complete -C 'mcpp self co' +complete -C 'mcpp build --cache=l' +complete -C 'mcpp self completion p' +complete -C 'mcpp build --off' +complete -C 'mcpp emit xpkg --output=artifact' +''' + (self.root / "artifact.lua").touch() + result = self.run_command([self.shell("fish"), "--no-config", "-c", code]) + self.assertEqual([line.split("\t")[0] for line in result], + ["completion", "config", "--cache=local", "pwsh", "--offline", "--output=artifact.lua"]) + + def test_powershell(self): + code = r''' +. "$env:MCPP_HOME/config/shell/mcpp.ps1" +. "$env:MCPP_HOME/config/shell/mcpp.ps1" +foreach ($line in @('mcpp self co', 'mcpp build --cache=l', 'mcpp self completion p', 'mcpp self ')) { + [System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches | + ForEach-Object { $_.CompletionText } +} +$line = 'mcpp emit xpkg --output=artifact' +[System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches | + ForEach-Object { $_.CompletionText } +''' + (self.root / "artifact.lua").touch() + result = self.run_command([self.shell("pwsh"), "-NoProfile", "-NonInteractive", "-Command", code]) + self.assertEqual(result[:4], ["completion", "config", "--cache=local", "pwsh"]) + self.assertIn("doctor", result[4:]) + self.assertTrue(result[-1].startswith("--output="), result) + self.assertIn("artifact.lua", result[-1]) + + def test_standalone_installer_quotes_paths_and_does_not_duplicate_rc_lines(self): + payload = self.root / "payload" / "mcpp-test" / "bin" + payload.mkdir(parents=True) + shutil.copy2(MCPP, payload / "mcpp") + archive = self.root / "release.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(payload.parent, arcname="mcpp-test") + # Only downloads are substituted; extraction, binary execution and rc + # editing all run through the production installer. + curl = self.bin / "curl" + curl.write_text('''#!/usr/bin/env bash +while (($#)); do + if [[ $1 == -o ]]; then destination=$2; shift 2; else url=$1; shift; fi +done +[[ $url == *.tar.gz ]] || exit 22 +cp "$MCPP_TEST_ARCHIVE" "$destination" +''') + curl.chmod(0o755) + prefix = self.root / "custom ' $prefix `literal`" + self.env.update(HOME=str(self.root / "user"), SHELL="/bin/bash", + MCPP_PREFIX=str(prefix), MCPP_TEST_ARCHIVE=str(archive)) + self.run_command(["bash", str(ROOT / "install.sh")]) + rc = Path(self.env["HOME"]) / ".bashrc" + before = rc.read_text() + self.run_command(["bash", str(ROOT / "install.sh")]) + self.assertEqual(before, rc.read_text()) + output = self.run_command(["bash", "--noprofile", "--norc", "-c", + f"source {shlex.quote(str(rc))}; complete -p mcpp"]) + self.assertEqual(output, ["complete -F _mcpp_complete mcpp"]) + self.assertTrue((prefix / "config" / "shell" / "mcpp.ps1").is_file()) + for name in ("zsh", "fish", "pwsh"): + shell = self.shell(name) + user = self.root / f"user-{name}" + config = user / "config" + self.env.update(HOME=str(user), SHELL=shell, + ZDOTDIR=str(user / "zsh"), XDG_CONFIG_HOME=str(config)) + if name == "zsh": + rc = user / "zsh" / ".zshrc" + source = r''' +if [[ -n $MCPP_TEST_ZSH_ROOT ]]; then + module_path=("$MCPP_TEST_ZSH_ROOT"/usr/lib/*/zsh/*(N/)) +fi +compdef() { :; } +source "$MCPP_TEST_RC" +(( $+functions[_mcpp_complete] )) +''' + argv = [shell, "-f", "-c", source] + elif name == "fish": + rc = config / "fish" / "config.fish" + argv = [shell, "--no-config", "-c", + 'source "$MCPP_TEST_RC"; functions -q __mcpp_candidates'] + else: + rc = Path(self.run_command([shell, "-NoProfile", "-Command", + "$PROFILE.CurrentUserAllHosts"])[0]) + source = r''' +. $env:MCPP_TEST_RC +$line = 'mcpp self co' +$completionMatches = [System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches +if ('config' -notin $completionMatches.CompletionText) { exit 1 } +''' + argv = [shell, "-NoProfile", "-NonInteractive", "-Command", source] + self.run_command(["bash", str(ROOT / "install.sh")]) + before = rc.read_text() + self.run_command(["bash", str(ROOT / "install.sh")]) + self.assertEqual(before, rc.read_text(), name) + self.env["MCPP_TEST_RC"] = str(rc) + self.run_command(argv) + self.env.update(HOME=str(self.root / "optout"), MCPP_NO_PATH="1", MCPP_NO_COMPLETION="1") + self.run_command(["bash", str(ROOT / "install.sh")]) + self.assertFalse((Path(self.env["HOME"]) / ".bashrc").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_shell_completion.cpp b/tests/unit/test_shell_completion.cpp new file mode 100644 index 000000000..7c3856db3 --- /dev/null +++ b/tests/unit/test_shell_completion.cpp @@ -0,0 +1,77 @@ +#include + +import std; +import mcpp.cli.completion; +import mcpplibs.cmdline; + +namespace { +namespace completion = mcpp::cli::completion; +namespace cl = mcpplibs::cmdline; + +completion::App application() { + return completion::App("mcpp") + .option(cl::Option("offline").global()) + .subcommand(completion::App("build") + .option(cl::Option("cache").takes_value()) + .option(cl::Option("package").short_name('p').takes_value()) + .option(cl::Option("output").short_name('o').takes_value().value_name("FILE"))) + .subcommand(completion::App("self") + .subcommand(completion::App("config") + .option(cl::Option("mirror").takes_value()))) + .subcommand(completion::App("secret").description("(internal) Hidden")); +} + +completion::Result complete(std::initializer_list words) { + std::vector tokens(words); + return completion::candidates(application().command, tokens); +} +} + +TEST(ShellCompletion, RootAndNestedCommandsComeFromTheParserDefinition) { + EXPECT_EQ(complete({""}).words, (std::vector{"build", "self"})); + EXPECT_EQ(complete({"self", "c"}).words, (std::vector{"config"})); + EXPECT_EQ(complete({"--offline", "self", ""}).words, (std::vector{"config"})); +} + +TEST(ShellCompletion, LocalShortAndInheritedGlobalOptionsAreOffered) { + EXPECT_EQ(complete({"build", "--o"}).words, + (std::vector{"--offline", "--output"})); + EXPECT_EQ(complete({"build", "-o"}).words, (std::vector{"-o"})); + EXPECT_EQ(complete({"self", "config", "--o"}).words, + (std::vector{"--offline"})); +} + +TEST(ShellCompletion, ValuesConsumeCommandLikeTokensWithoutChangingContext) { + EXPECT_EQ(complete({"build", "-p", "self", "--ca"}).words, + (std::vector{"--cache"})); + EXPECT_EQ(complete({"build", "--cache", "l"}).words, + (std::vector{"local"})); + EXPECT_EQ(complete({"build", "--cache=l"}).words, + (std::vector{"--cache=local"})); + EXPECT_TRUE(complete({"build", "--package", ""}).words.empty()); + EXPECT_FALSE(complete({"build", "--package", ""}).files); +} + +TEST(ShellCompletion, PathsUseShellNativeCompletionAndPassthroughIsNotMcpp) { + EXPECT_TRUE(complete({"build", "-o", "some path/"}).files); + EXPECT_TRUE(complete({"build", "--output=some path/"}).files); + EXPECT_EQ(complete({"build", "--output=some path/"}).file_prefix, "--output="); + EXPECT_TRUE(complete({"build", "-osome path/"}).files); + auto result = complete({"build", "--", "--cache"}); + EXPECT_TRUE(result.words.empty()); + EXPECT_TRUE(result.files); +} + +TEST(ShellCompletion, RecordingMetadataPreservesTheOriginalParserAndAction) { + bool called = false; + auto app = application(); + (void)app.action([&](const cl::ParsedArgs&) { called = true; }); + std::vector argv{"mcpp", "--offline"}; + auto parsed = app.parse_from(argv); + ASSERT_TRUE(parsed.has_value()); + EXPECT_TRUE(parsed->is_flag_set("offline")); + app.run(*parsed); + EXPECT_TRUE(called); + auto result = complete({"build", "--cache", ""}); + EXPECT_EQ(result.words, (std::vector{"global", "local", "off"})); +}