diff --git a/.gitignore b/.gitignore index 3823322d4..7ade8af04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ mutants.out*/ +target-thin/ diff --git a/Cargo.lock b/Cargo.lock index aece46daf..5be4ad752 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,6 +378,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bpaf" +version = "0.9.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c670d65eea33846872d5ccf668c00a94c5c67dbdcc0289ea1c362671ce1ddb82" +dependencies = [ + "bpaf_derive", + "owo-colors", + "supports-color", +] + +[[package]] +name = "bpaf_derive" +version = "0.5.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7e98cee839b19076cb3ce1afdb62bb182e04ff5f71f70188827002fae91094" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "brush" version = "0.4.0" @@ -390,11 +412,11 @@ name = "brush-builtins" version = "0.2.0" dependencies = [ "anyhow", + "bpaf", "brush-core", "brush-parser", "cfg-if", "chrono", - "clap", "fancy-regex", "itertools 0.14.0", "nix 0.31.3", @@ -416,12 +438,12 @@ dependencies = [ "async-recursion", "async-trait", "bon", + "bpaf", "brush-parser", "cached", "cfg-if", "check_elevation", "chrono", - "clap", "color-print", "command-fds", "fancy-regex", @@ -542,8 +564,8 @@ dependencies = [ name = "brush-experimental-builtins" version = "0.1.0" dependencies = [ + "bpaf", "brush-core", - "clap", "serde_json", ] @@ -610,6 +632,7 @@ dependencies = [ "anyhow", "assert_cmd", "assert_fs", + "bpaf", "brush-builtins", "brush-core", "brush-coreutils-builtins", @@ -617,7 +640,6 @@ dependencies = [ "brush-interactive", "brush-parser", "brush-test-harness", - "clap", "color-print", "colored", "const_format", @@ -637,7 +659,6 @@ dependencies = [ "nix 0.31.3", "os-release", "predicates", - "pretty_assertions", "regex", "schemars", "serde", @@ -664,8 +685,8 @@ dependencies = [ "anyhow", "assert_cmd", "assert_fs", + "bpaf", "cfg_aliases", - "clap", "colored", "descape", "diff", @@ -864,15 +885,6 @@ dependencies = [ "clap_derive", ] -[[package]] -name = "clap-markdown" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2a2617956a06d4885b490697b5307ebb09fec10b088afc18c81762d848c2339" -dependencies = [ - "clap", -] - [[package]] name = "clap_builder" version = "4.6.6" @@ -886,15 +898,6 @@ dependencies = [ "terminal_size", ] -[[package]] -name = "clap_complete" -version = "4.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.4" @@ -913,16 +916,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "clap_mangen" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" -dependencies = [ - "clap", - "roff", -] - [[package]] name = "color-print" version = "0.3.7" @@ -3683,12 +3676,6 @@ dependencies = [ "libc", ] -[[package]] -name = "roff" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" - [[package]] name = "ron" version = "0.12.2" @@ -6516,9 +6503,6 @@ dependencies = [ "brush-shell", "cfg_aliases", "clap", - "clap-markdown", - "clap_complete", - "clap_mangen", "libc", "num_cpus", "pty-process", diff --git a/benchmarks/real-world/config-lint.sh b/benchmarks/real-world/config-lint.sh new file mode 100755 index 000000000..192ffdf8a --- /dev/null +++ b/benchmarks/real-world/config-lint.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Simulated configuration linter. +# +# Models a CI-side validation tool: for each config entry, a fresh getopts +# pass parses that entry's option string, followed by declaration-heavy +# checks and formatted reporting. Deterministic output. +# +# Usage: config-lint.sh [count] +# count number of config entries to validate (default 250) + +set -eu + +COUNT=${1:-250} + +total=0 +warnings=0 +errors=0 +strict=no +prefix="." + +validate() { + local id=$1 + shift + + local path=$prefix scope=global mode=read-only verbose=no bad=0 + local opt err=0 + + while getopts ":p:s:m:v" opt "$@"; do + case "$opt" in + p) path=$OPTARG ;; + s) scope=$OPTARG ;; + m) mode=$OPTARG ;; + v) verbose=yes ;; + \?) err=1 ; break ;; + esac + done + + # Validation rules (pure builtin work: patterns, substring ops, arithmetic). + case "$scope" in + global|user|session) ;; + *) bad=$((bad + 1)) ;; + esac + case "$mode" in + read-only|read-write) ;; + *) bad=$((bad + 1)) ;; + esac + if [ "${#path}" -gt 64 ]; then + bad=$((bad + 1)) + fi + if [ "$((id % 7))" -eq 0 ]; then + warnings=$((warnings + 1)) + fi + + if [ "$verbose" = yes ]; then + printf 'entry %03d: path=%s scope=%-8s mode=%-10s\n' \ + "$id" "$path" "$scope" "$mode" + fi + + if [ "$bad" -gt 0 ]; then + errors=$((errors + bad)) + printf 'entry %03d: %d problem(s)\n' "$id" "$bad" + fi + + total=$((total + 1)) +} + +i=0 +while [ "$i" -lt "$COUNT" ]; do + i=$((i + 1)) + case $((i % 3)) in + 0) validate "$i" -p "/etc/app/svc$i.conf" -s user -m read-write -v ;; + 1) validate "$i" -p "/var/lib/app/$i.db" -m read-only ;; + 2) validate "$i" -s session -m read-write -v ;; + esac +done + +printf 'checked %d entries: %d warning(s), %d error(s)\n' \ + "$COUNT" "$warnings" "$errors" +[ "$errors" -eq 0 ] diff --git a/benchmarks/real-world/deploy-sim.sh b/benchmarks/real-world/deploy-sim.sh new file mode 100755 index 000000000..e48d60200 --- /dev/null +++ b/benchmarks/real-world/deploy-sim.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Simulated multi-target deployment script. +# +# A deliberately "typical" operations script: option parsing, directory +# juggling, declarations, formatted reporting. Deterministic by construction +# (no timestamps, no randomness) so two shells can be diffed byte-for-byte. +# +# Builtin-parse density comes from the per-artifact loop: every iteration +# re-parses getopts-style option handling plus declare/local/printf/test calls. + +set -euo pipefail + +TARGET="" +JOBS=4 +VERBOSE=0 +DRY_RUN=no +COMPRESS=gzip +TAG="latest" +VERSION=2 + +usage() { + printf 'usage: %s [-v] [-n] [-j jobs] [-c compressor] [-t tag] target\n' "$0" >&2 +} + +die() { + printf 'deploy: error: %s\n' "$1" >&2 + exit 2 +} + +[ $# -gt 0 ] || { usage; exit 2; } + +while getopts ":vnj:c:t:h-" opt; do + case "$opt" in + v) VERBOSE=$((VERBOSE + 1)) ;; + n) DRY_RUN=yes ;; + j) JOBS=$OPTARG + case "$JOBS" in (*[!0-9]*|'') die "-j expects a number" ;; esac ;; + c) COMPRESS=$OPTARG ;; + t) TAG=$OPTARG ;; + h) usage; exit 0 ;; + -) break ;; + \?) die "invalid option: -$OPTARG" ;; + esac +done +shift $((OPTIND - 1)) + +TARGET=${1:?missing target argument} +case "$TARGET" in + staging|production|canary) ;; + *) die "unknown target '$TARGET'" ;; +esac + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +mkdir "$WORK/pkg" "$WORK/out" "$WORK/cache" + +pushd "$WORK" > /dev/null +trap 'popd > /dev/null; rm -rf "$WORK"' EXIT + +ARTIFACTS=( + "core:1.0.${VERSION}:stable" + "cli:1.2.${VERSION}:stable" + "daemon:0.9.${VERSION}:beta" + "web:2.3.${VERSION}:beta" + "tools:0.0.${VERSION}:experimental" +) + +report_row() { + local name=$1 version=$2 channel=$3 size=$4 status=$5 + printf '| %-8s | %-8s | %-12s | %6d KiB | %-8s |\n' \ + "$name" "$version" "$channel" "$size" "$status" +} + +build_artifact() { + local spec=$1 + local name=${spec%%:*} + local rest=${spec#*:} + local version=${rest%%:*} + local channel=${rest##*:} + + # Local scope + string manipulation per artifact. + local pkg_dir="$WORK/pkg/$name" + mkdir -p "$pkg_dir" + printf '%s %s (%s)\nbuilt for %s with %s\n' \ + "$name" "$version" "$channel" "$TARGET" "$COMPRESS" \ + > "$pkg_dir/README" + + local size=0 + while read -r line; do + size=$((size + ${#line})) + done < "$pkg_dir/README" + + if [ "$DRY_RUN" = yes ]; then + status=skipped + else + tar -cf - -C "$pkg_dir" . 2>/dev/null | wc -c > /dev/null + status=built + fi + + report_row "$name" "$version" "$channel" "$size" "$status" +} + +printf 'deploy plan for %s (jobs=%d, dry-run=%s, verbosity=%d)\n' \ + "$TARGET" "$JOBS" "$DRY_RUN" "$VERBOSE" +printf '+----------+----------+--------------+-----------+----------+\n' +printf '| name | version | channel | size | status |\n' +printf '+----------+----------+--------------+-----------+----------+\n' + +for spec in "${ARTIFACTS[@]}"; do + build_artifact "$spec" +done + +printf '+----------+----------+--------------+-----------+----------+\n' + +# Directory bookkeeping round-trip. +for d in pkg out cache; do + pushd "$d" > /dev/null + pwd > /dev/null + popd > /dev/null +done + +# Export checks: environment visible to a fresh shell. +export DEPLOY_TARGET="$TARGET" DEPLOY_TAG="$TAG" +env | grep '^DEPLOY_' | LC_ALL=C sort +unset DEPLOY_TARGET DEPLOY_TAG + +echo "done" diff --git a/benchmarks/real-world/interp-loop.sh b/benchmarks/real-world/interp-loop.sh new file mode 100755 index 000000000..2d685a2dd --- /dev/null +++ b/benchmarks/real-world/interp-loop.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Pure-interpretation loop: assignments and arithmetic, no external commands, +# no builtin argument parsing. Isolates interpreter cost from parser cost. + +set -eu + +x=0 +for ((i = 0; i < 60000; i++)); do + x=$((x + i % 7)) +done + +printf '%d\n' "$x" diff --git a/benchmarks/real-world/wordops.sh b/benchmarks/real-world/wordops.sh new file mode 100755 index 000000000..9f1b1372a --- /dev/null +++ b/benchmarks/real-world/wordops.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# String/pattern/array workloads: parameter expansion, [[ ]] tests, printf, +# arrays. Builtin-heavy but not option-parsing-heavy. + +set -eu + +words=(alpha Beta GAMMA delta epsilon zeta OMEGA theta) +out="" + +for i in {1..400}; do + for w in "${words[@]}"; do + l=${w,,} + u=${w^^} + if [[ $l == a* ]]; then + out+="$(printf '%s|%s|%d;' "$u" "$l" "${#w}")" + elif [[ $l == *[et]* ]]; then + out+="${w:0:2}=${#out}," + fi + done + + if ((i % 100 == 0)); then + arr=("${out//;/ }") + out+="${#arr[@]}#${arr[0]:0:3}." + fi +done + +n=${#out} +printf '%d %d\n' "$n" "$((n % 97))" diff --git a/benchmarks/three-way.py b/benchmarks/three-way.py new file mode 100755 index 000000000..5ac98617c --- /dev/null +++ b/benchmarks/three-way.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Multi-shell benchmark: an oracle shell vs N candidate builds. + +Runs each workload under all three shells with interleaved, pinned sampling +so machine drift affects every shell equally, verifies byte-for-byte output +parity before timing, and reports median +- MAD wall time plus pairwise +speedup ratios. A separate pass reads peak RSS (VmHWM) per workload. + +Usage: + three-way.py -s bash=/usr/bin/bash -s clap=/path/brush -s bpaf=/path/brush ... +""" + +import argparse +import json +import shutil +import statistics +import subprocess +import sys +import time + +BRUSH_FLAGS = [ + "--norc", + "--noprofile", + "--input-backend=basic", + "--disable-bracketed-paste", + "--disable-color", +] + +MAD_SCALE = 1.4826 +DEFAULT_SAMPLES = 15 +STARTUP_BATCH = 20 + + +def brush_like(name): + return "brush" in name + + +def shell_argv(shell, script, args): + argv = [shell["path"]] + if brush_like(shell["name"]): + argv += BRUSH_FLAGS + else: + argv += ["--norc", "--noprofile"] + if script is None: + argv += ["-c", "exit 0"] + else: + argv += [script] + args + return argv + + +def run(argv, core=None): + if core is not None: + argv = ["taskset", "-c", str(core)] + argv + return subprocess.run(argv, capture_output=True, text=True) + + +def timed_run(argv, core=None): + start = time.perf_counter() + result = run(argv, core) + elapsed = time.perf_counter() - start + if result.returncode != 0: + raise RuntimeError( + f"workload failed ({result.returncode}): {' '.join(argv)}\n" + f"stderr: {result.stderr[:2000]}" + ) + return elapsed, result.stdout + + +def peak_rss_kib(shell, script, args, core): + """Peak RSS of the shell process running the workload, via VmHWM.""" + if not brush_like(shell["name"]): + probe = "grep VmHWM /proc/$$/status" + else: + probe = "grep VmHWM /proc/$$/status" + + inner = f". '{script}' {' '.join(args)} >/dev/null 2>&1; {probe}" + argv = [shell["path"]] + argv += BRUSH_FLAGS if brush_like(shell["name"]) else ["--norc", "--noprofile"] + argv += ["-c", inner] + + result = run(argv, core) + for line in result.stdout.splitlines(): + if line.startswith("VmHWM:"): + return int(line.split()[1]) + return None + + +def stats(values_ns): + med = statistics.median(values_ns) + mad = statistics.median(abs(v - med) for v in values_ns) * MAD_SCALE if len(values_ns) > 1 else 0.0 + return med, mad + + +def fmt_ms(seconds): + return f"{seconds * 1e3:8.2f} ms" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-s", "--shell", action="append", required=True, + help="name=path; first entry is the oracle (e.g. bash), repeatable", + ) + parser.add_argument("--samples", type=int, default=DEFAULT_SAMPLES) + parser.add_argument("--core", type=int, default=None, help="pin all runs to this CPU") + parser.add_argument("--skip-parity", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + shells = [] + for spec in args.shell: + name, _, path = spec.partition("=") + path = shutil.which(path) or path + shells.append({"name": name, "path": path}) + + here = "/".join(__file__.split("/")[:-1]) + workloads = [ + {"name": "startup", "script": None, "args": [], "batch": STARTUP_BATCH}, + {"name": "interp-loop", "script": f"{here}/real-world/interp-loop.sh", "args": [], "batch": 1}, + {"name": "wordops", "script": f"{here}/real-world/wordops.sh", "args": [], "batch": 1}, + {"name": "config-lint-500", "script": f"{here}/real-world/config-lint.sh", "args": ["500"], "batch": 1}, + {"name": "deploy-sim", "script": f"{here}/real-world/deploy-sim.sh", "args": ["staging"], "batch": 1}, + ] + + # Parity check: identical stdout and exit status across all three shells. + if not args.skip_parity: + for wl in workloads: + outputs = set() + for shell in shells: + batch = range(wl["batch"]) + outs = [] + for _ in batch: + _, out = timed_run(shell_argv(shell, wl["script"], wl["args"]), args.core) + outs.append(out) + outputs.add(outs[-1]) + if len(outputs) != 1: + print(f"PARITY FAILURE on {wl['name']}: outputs differ across shells", file=sys.stderr) + sys.exit(2) + + results = {w["name"]: {s["name"]: [] for s in shells} for w in workloads} + + orders = [shells[i:] + shells[:i] for i in range(len(shells))] + for sample in range(args.samples): + order = orders[sample % len(orders)] + for wl in workloads: + for shell in order: + total = 0.0 + for _ in range(wl["batch"]): + elapsed, _ = timed_run(shell_argv(shell, wl["script"], wl["args"]), args.core) + total += elapsed + results[wl["name"]][shell["name"]].append(total / wl["batch"]) + + if args.json: + payload = {} + for wl in workloads: + entry = {} + for shell in shells: + med, mad = stats(results[wl["name"]][shell["name"]]) + entry[shell["name"]] = {"median_ms": med * 1e3, "mad_ms": mad * 1e3} + payload[wl["name"]] = entry + print(json.dumps(payload, indent=2)) + return + + print() + print("=" * 76) + print("Three-way shell benchmark (interleaved samples, median ± MAD·1.4826)") + print(f"samples={args.samples}" + (f", pinned to cpu{args.core}" if args.core is not None else "")) + print("=" * 76) + + for wl in workloads: + print(f"\n🧪 {wl['name']}") + meds = {} + for shell in shells: + med, mad = stats(results[wl["name"]][shell["name"]]) + meds[shell["name"]] = med + print(" %10s: %s (±%.2f ms)" % (shell["name"], fmt_ms(med), mad * 1e3)) + + oracle_name = shells[0]["name"] + oracle = meds[oracle_name] + for shell in shells[1:]: + sname = shell["name"] + ratio = oracle / meds[sname] + pct = (1.0 / ratio - 1.0) * 100 + print(f" vs {oracle_name}: {sname} is {ratio:.2f}× ({pct:+.1f}%)") + + if len(shells) > 2: + print(" pairwise:") + for i in range(1, len(shells)): + for j in range(i + 1, len(shells)): + n_i, n_j = shells[i]["name"], shells[j]["name"] + r = meds[n_j] / meds[n_i] + winner = n_i if meds[n_i] < meds[n_j] else n_j + print(f" {n_i} vs {n_j}: {r:.2f}× ({winner} faster)") + + print("\n📊 Peak RSS (VmHWM, KiB):") + header = " " + "".join(f"{s['name']:>16}" for s in shells) + print(header) + for wl in workloads: + if wl["script"] is None: + continue + row = f" {wl['name']:>14}" + for shell in shells: + kib = peak_rss_kib(shell, wl["script"], wl["args"], args.core) + row += f"{kib if kib is not None else '?':>16}" + print(row) + + +if __name__ == "__main__": + main() diff --git a/brush-builtins/Cargo.toml b/brush-builtins/Cargo.toml index fbc102a19..ab44281c1 100644 --- a/brush-builtins/Cargo.toml +++ b/brush-builtins/Cargo.toml @@ -132,7 +132,7 @@ brush-core = { version = "^0.5.0", path = "../brush-core" } brush-parser = { version = "^0.4.0", path = "../brush-parser" } cfg-if = "1.0.4" chrono = "0.4.44" -clap = { version = "4.6.0", features = ["derive", "wrap_help"] } +bpaf = { version = "0.9.27", features = ["derive", "bright-color"] } fancy-regex = "0.19.0" itertools = "0.14.0" strum = "0.28.0" diff --git a/brush-builtins/src/alias.rs b/brush-builtins/src/alias.rs index 8ba642ffd..383fdc0f3 100644 --- a/brush-builtins/src/alias.rs +++ b/brush-builtins/src/alias.rs @@ -1,23 +1,35 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionResult, builtins}; /// Manage aliases within the shell. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct AliasCommand { /// Print all defined aliases in a reusable format. - #[arg(short = 'p')] + #[bpaf(short('p'))] print: bool, /// List of aliases to display or update. - #[arg(name = "name[=value]")] + #[bpaf(positional("name[=value]"))] aliases: Vec, } impl builtins::Command for AliasCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + alias_command() + } + + fn about() -> &'static str { + "Manage aliases within the shell." + } + + fn synopsis() -> &'static str { + "[-p] [name[=value]]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/bg.rs b/brush-builtins/src/bg.rs index 97d834223..93ea3017a 100644 --- a/brush-builtins/src/bg.rs +++ b/brush-builtins/src/bg.rs @@ -1,18 +1,32 @@ -use clap::Parser; +use bpaf::Bpaf; + use std::io::Write; use brush_core::{ExecutionResult, builtins}; /// Moves a job to run in the background. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct BgCommand { /// List of job specs to move to background. + #[bpaf(positional("JOB_SPECS"))] job_specs: Vec, } impl builtins::Command for BgCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + bg_command() + } + + fn about() -> &'static str { + "Moves a job to run in the background." + } + + fn synopsis() -> &'static str { + "[JOB_SPECS]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/bind.rs b/brush-builtins/src/bind.rs index de41770c9..3966b4dc4 100644 --- a/brush-builtins/src/bind.rs +++ b/brush-builtins/src/bind.rs @@ -1,6 +1,6 @@ -use clap::{Parser, ValueEnum}; +use bpaf::Parser; use itertools::Itertools as _; -use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc}; +use std::{collections::HashMap, io::Write, str::FromStr, sync::Arc}; use strum::IntoEnumIterator; use tokio::sync::Mutex; @@ -11,20 +11,30 @@ use brush_core::{ }; /// Identifier for a keymap -#[derive(Clone, ValueEnum)] +#[derive(Clone)] enum BindKeyMap { - #[clap(name = "emacs-standard", alias = "emacs")] EmacsStandard, - #[clap(name = "emacs-meta")] EmacsMeta, - #[clap(name = "emacs-ctlx")] EmacsCtlx, - #[clap(name = "vi-command", aliases = &["vi", "vi-move"])] ViCommand, - #[clap(name = "vi-insert")] ViInsert, } +impl FromStr for BindKeyMap { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "emacs-standard" | "emacs" => Ok(Self::EmacsStandard), + "emacs-meta" => Ok(Self::EmacsMeta), + "emacs-ctlx" => Ok(Self::EmacsCtlx), + "vi-command" | "vi" | "vi-move" => Ok(Self::ViCommand), + "vi-insert" => Ok(Self::ViInsert), + _ => Err(format!("invalid keymap: {s}")), + } + } +} + impl BindKeyMap { const fn is_vi(&self) -> bool { matches!(self, Self::ViCommand | Self::ViInsert) @@ -40,87 +50,102 @@ impl BindKeyMap { } /// Inspect and modify key bindings and other input configuration. -#[derive(Parser)] pub(crate) struct BindCommand { - /// Name of key map to use. - #[arg(short = 'm')] keymap: Option, - /// List functions. - #[arg(short = 'l')] list_funcs: bool, - /// List functions and bindings. - #[arg(short = 'P')] list_funcs_and_bindings: bool, - /// List functions and bindings in a format suitable for use as input. - #[arg(short = 'p')] list_funcs_and_bindings_reusable: bool, - /// List key sequences that invoke macros. - #[arg(short = 'S')] list_key_seqs_that_invoke_macros: bool, - /// List key sequences that invoke macros in a format suitable for use as input. - #[arg(short = 's')] list_key_seqs_that_invoke_macros_reusable: bool, - /// List variables. - #[arg(short = 'V')] list_vars: bool, - /// List variables in a format suitable for use as input. - #[arg(short = 'v')] list_vars_reusable: bool, - /// Find the keys bound to the given named function. - #[arg(short = 'q', value_name = "FUNC_NAME")] query_func_bindings: Option, - /// Remove all bindings for the given named function. - #[arg(short = 'u', value_name = "FUNC_NAME")] remove_func_bindings: Option, - /// Remove the binding for the given key sequence. - #[arg(short = 'r', value_name = "KEY_SEQ")] remove_key_seq_binding: Option, - /// Import bindings from the given file. - #[arg(short = 'f', value_name = "PATH")] bindings_file: Option, - /// Bind key sequence to command. - #[arg(short = 'x', value_name = "BINDING")] key_seq_bindings: Vec, - /// List key sequence bindings. - #[arg(short = 'X')] list_key_seq_bindings: bool, - /// Key sequence binding to readline function or command. key_sequence: Option, } -#[derive(Debug, thiserror::Error)] -pub(crate) enum BindError { - /// Unknown function specified. - #[error("unknown function: {0}")] - UnknownFunction(String), - - /// Unknown key binding function. - #[error("unknown key binding function: {0}")] - UnknownKeyBindingFunction(String), - - /// Unimplemented functionality. - #[error("unimplemented: {0}")] - Unimplemented(&'static str), - - /// An I/O error occurred. - #[error("I/O error occurred")] - IoError(#[from] std::io::Error), - - /// A binding parse error occurred. - #[error(transparent)] - BindingParseError(#[from] brush_parser::BindingParseError), -} +impl builtins::Command for BindCommand { + type Error = BindError; -impl brush_core::BuiltinError for BindError {} + fn parser() -> impl bpaf::Parser { + let keymap = bpaf::short('m') + .help("Name of key map to use.") + .argument::("KEYMAP") + .optional(); + let list_funcs = bpaf::short('l').help("List functions.").switch(); + let list_funcs_and_bindings = bpaf::short('P') + .help("List functions and bindings.") + .switch(); + let list_funcs_and_bindings_reusable = bpaf::short('p') + .help("List functions and bindings in a format suitable for use as input.") + .switch(); + let list_key_seqs_that_invoke_macros = bpaf::short('S') + .help("List key sequences that invoke macros.") + .switch(); + let list_key_seqs_that_invoke_macros_reusable = bpaf::short('s') + .help("List key sequences that invoke macros in a format suitable for use as input.") + .switch(); + let list_vars = bpaf::short('V').help("List variables.").switch(); + let list_vars_reusable = bpaf::short('v') + .help("List variables in a format suitable for use as input.") + .switch(); + let query_func_bindings = bpaf::short('q') + .help("Find the keys bound to the given named function.") + .argument::("FUNC_NAME") + .optional(); + let remove_func_bindings = bpaf::short('u') + .help("Remove all bindings for the given named function.") + .argument::("FUNC_NAME") + .optional(); + let remove_key_seq_binding = bpaf::short('r') + .help("Remove the binding for the given key sequence.") + .argument::("KEY_SEQ") + .optional(); + let bindings_file = bpaf::short('f') + .help("Import bindings from the given file.") + .argument::("PATH") + .optional(); + let key_seq_bindings = bpaf::short('x') + .help("Bind key sequence to command.") + .argument::("BINDING") + .many(); + let list_key_seq_bindings = bpaf::short('X') + .help("List key sequence bindings.") + .switch(); + let key_sequence = bpaf::positional::("KEY_SEQUENCE") + .help("Key sequence binding to readline function or command.") + .optional(); + + bpaf::construct!(BindCommand { + keymap, + list_funcs, + list_funcs_and_bindings, + list_funcs_and_bindings_reusable, + list_key_seqs_that_invoke_macros, + list_key_seqs_that_invoke_macros_reusable, + list_vars, + list_vars_reusable, + query_func_bindings, + remove_func_bindings, + remove_key_seq_binding, + bindings_file, + key_seq_bindings, + list_key_seq_bindings, + key_sequence, + }) + } -impl From<&BindError> for brush_core::ExecutionExitCode { - fn from(_err: &BindError) -> Self { - Self::GeneralError + fn about() -> &'static str { + "Inspect and modify key bindings and other input configuration." } -} -impl builtins::Command for BindCommand { - type Error = BindError; + fn synopsis() -> &'static str { + "[-lpsPSVX] [-m KEYMAP] [-q|-u|-r ARG] [-f PATH] [-x BINDING]... [KEY_SEQUENCE]" + } async fn execute( &self, @@ -273,6 +298,37 @@ impl BindCommand { } } +#[derive(Debug, thiserror::Error)] +pub(crate) enum BindError { + /// Unknown function specified. + #[error("unknown function: {0}")] + UnknownFunction(String), + + /// Unknown key binding function. + #[error("unknown key binding function: {0}")] + UnknownKeyBindingFunction(String), + + /// Unimplemented functionality. + #[error("unimplemented: {0}")] + Unimplemented(&'static str), + + /// An I/O error occurred. + #[error("I/O error occurred")] + IoError(#[from] std::io::Error), + + /// A binding parse error occurred. + #[error(transparent)] + BindingParseError(#[from] brush_parser::BindingParseError), +} + +impl brush_core::BuiltinError for BindError {} + +impl From<&BindError> for brush_core::ExecutionExitCode { + fn from(_err: &BindError) -> Self { + Self::GeneralError + } +} + fn parse_key_sequence(input: &str) -> Result { // First trim any whitespace. let input = input.trim(); diff --git a/brush-builtins/src/break_.rs b/brush-builtins/src/break_.rs index d16a03948..b5748ec5b 100644 --- a/brush-builtins/src/break_.rs +++ b/brush-builtins/src/break_.rs @@ -1,18 +1,30 @@ -use clap::Parser; +use bpaf::Parser; use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; /// Breaks out of a control-flow loop. -#[derive(Parser)] pub(crate) struct BreakCommand { - /// If specified, indicates which nested loop to break out of. - #[clap(default_value_t = 1)] which_loop: i8, } impl builtins::Command for BreakCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let which_loop = bpaf::positional::("WHICH_LOOP") + .help("If specified, indicates which nested loop to break out of.") + .fallback(1); + bpaf::construct!(BreakCommand { which_loop }) + } + + fn about() -> &'static str { + "Breaks out of a control-flow loop." + } + + fn synopsis() -> &'static str { + "[N]" + } + async fn execute( &self, _context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/builtin_.rs b/brush-builtins/src/builtin_.rs index 271f0633c..a06977800 100644 --- a/brush-builtins/src/builtin_.rs +++ b/brush-builtins/src/builtin_.rs @@ -1,11 +1,8 @@ -use clap::Parser; - use brush_core::{ExecutionResult, builtins}; /// Directly invokes a built-in, without going through typical search order. -#[derive(Default, Parser)] +#[derive(Default)] pub(crate) struct BuiltinCommand { - #[clap(skip)] args: Vec, } @@ -18,6 +15,21 @@ impl builtins::DeclarationCommand for BuiltinCommand { impl builtins::Command for BuiltinCommand { type Error = brush_core::Error; + // N.B. Arguments are passed directly via `set_declarations`; the parser is + // used only for help rendering. + fn parser() -> impl bpaf::Parser { + let args = bpaf::pure(Vec::new()); + bpaf::construct!(BuiltinCommand { args }) + } + + fn about() -> &'static str { + "Directly invokes a built-in, without going through typical search order." + } + + fn synopsis() -> &'static str { + "SHELL_BUILTIN [ARGS]..." + } + async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/caller.rs b/brush-builtins/src/caller.rs index 58c7b3a84..bcf2b5a7f 100644 --- a/brush-builtins/src/caller.rs +++ b/brush-builtins/src/caller.rs @@ -1,17 +1,31 @@ +use bpaf::Bpaf; + use brush_core::{ExecutionResult, builtins, callstack}; -use clap::Parser; use std::io::Write; /// Return the context of the current subroutine call. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct CallerCommand { /// The number of call frames to go back. + #[bpaf(positional("EXPR"))] expr: Option, } impl builtins::Command for CallerCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + caller_command() + } + + fn about() -> &'static str { + "Return the context of the current subroutine call." + } + + fn synopsis() -> &'static str { + "[EXPR]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/cd.rs b/brush-builtins/src/cd.rs index fdae4ca69..85826caaa 100644 --- a/brush-builtins/src/cd.rs +++ b/brush-builtins/src/cd.rs @@ -1,30 +1,22 @@ use std::io::Write; use std::path::PathBuf; -use clap::Parser; +use bpaf::Parser; use brush_core::{ExecutionResult, builtins, error}; /// Change the current shell working directory. -#[derive(Parser)] pub(crate) struct CdCommand { - /// Force following symlinks. - #[arg(short = 'L', overrides_with = "use_physical_dir")] - force_follow_symlinks: bool, - - /// Use physical dir structure without following symlinks. - #[arg(short = 'P', overrides_with = "force_follow_symlinks")] - use_physical_dir: bool, - /// Exit with non zero exit status if current working directory resolution fails. - #[arg(short = 'e')] exit_on_failed_cwd_resolution: bool, - /// Show file with extended attributes as a dir with extended - /// attributes. - #[arg(short = '@')] + /// Show file with extended attributes as a dir with extended attributes. file_with_xattr_as_dir: bool, + /// Whether an explicit physical/logical mode was requested; `Some(true)` + /// means physical (`-P`) and `Some(false)` means logical (`-L`). + mode: Option, + /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is /// converted to $OLDPWD. target_dir: Option, @@ -33,6 +25,46 @@ pub(crate) struct CdCommand { impl builtins::Command for CdCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let exit_on_failed_cwd_resolution = bpaf::short('e') + .help("Exit with non zero exit status if current working directory resolution fails.") + .switch(); + let file_with_xattr_as_dir = bpaf::short('@') + .help("Show file with extended attributes as a dir with extended attributes.") + .switch(); + + let physical = bpaf::short('P') + .help("Use physical dir structure without following symlinks.") + .req_flag(Some(true)); + let logical = bpaf::short('L') + .help("Force following symlinks.") + .req_flag(Some(false)); + + let mode = bpaf::construct!([physical, logical]).fallback(None); + + let target_dir = bpaf::positional::("TARGET_DIR") + .help( + "By default it is the value of the HOME shell variable. If `TARGET_DIR` is \"-\", \ + it is converted to $OLDPWD.", + ) + .optional(); + + bpaf::construct!(CdCommand { + exit_on_failed_cwd_resolution, + file_with_xattr_as_dir, + mode, + target_dir, + }) + } + + fn about() -> &'static str { + "Change the current shell working directory." + } + + fn synopsis() -> &'static str { + "[-LPe@] [TARGET_DIR]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -67,7 +99,7 @@ impl builtins::Command for CdCommand { } }; - if self.use_physical_dir + if self.mode == Some(true) || context .shell .options() diff --git a/brush-builtins/src/command.rs b/brush-builtins/src/command.rs index 5401181b0..a1b6216a5 100644 --- a/brush-builtins/src/command.rs +++ b/brush-builtins/src/command.rs @@ -1,4 +1,3 @@ -use clap::Parser; use std::{fmt::Display, io::Write, path::Path}; use brush_core::{ @@ -7,34 +6,71 @@ use brush_core::{ }; /// Directly invokes an external command, without going through typical search order. -#[derive(Default, Parser)] +#[derive(Default)] pub(crate) struct CommandCommand { /// Use default PATH value. - #[arg(short = 'p')] pub use_default_path: bool, /// Display a short description of the command. - #[arg(short = 'v')] pub print_description: bool, /// Display a more verbose description of the command. - #[arg(short = 'V')] pub print_verbose_description: bool, /// Command and arguments. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub command_and_args: Vec, } impl CommandCommand { fn command(&self) -> Option<&str> { - self.command_and_args.first().map(|s| s.as_str()) + // N.B. A leading `--` ends the builtin's option section; the command + // name starts after it. + self.command_and_args + .iter() + .position(|s| s != "--") + .map(|ix| self.command_and_args[ix].as_str()) } } impl builtins::Command for CommandCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let use_default_path = bpaf::short('p').help("Use default PATH value.").switch(); + let print_description = bpaf::short('v') + .help("Display a short description of the command.") + .switch(); + let print_verbose_description = bpaf::short('V') + .help("Display a more verbose description of the command.") + .switch(); + let command_and_args = bpaf::pure(Vec::new()); + + bpaf::construct!(CommandCommand { + use_default_path, + print_description, + print_verbose_description, + command_and_args, + }) + } + + fn about() -> &'static str { + "Directly invokes an external command, without going through typical search order." + } + + fn synopsis() -> &'static str { + "[-pvV] [COMMAND [ARG]...]" + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, args: Vec) { + self.command_and_args = args; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -132,8 +168,16 @@ impl CommandCommand { use_default_path: bool, ) -> Result { command_name.clone_into(&mut context.command_name); - let command_and_args = self + + // N.B. The spawned-command machinery expects the command name itself + // as the first element; leading `--` markers are skipped so it lands + // there. + let name_ix = self .command_and_args + .iter() + .position(|s| s.as_str() == command_name) + .unwrap_or(0); + let command_and_args = self.command_and_args[name_ix.min(self.command_and_args.len())..] .iter() .map(brush_core::CommandArg::from); diff --git a/brush-builtins/src/complete.rs b/brush-builtins/src/complete.rs index 32cec71b0..e93b529a4 100644 --- a/brush-builtins/src/complete.rs +++ b/brush-builtins/src/complete.rs @@ -1,99 +1,131 @@ -use clap::Parser; +use bpaf::Parser; use std::collections::HashMap; +use std::ffi::OsStr; use std::fmt::Write as _; use std::io::Write; use brush_core::completion::{self, CompleteAction, CompleteOption, Spec}; use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape}; -#[derive(Parser)] struct CommonCompleteCommandArgs { - /// Options governing the behavior of completions. - #[arg(short = 'o')] options: Vec, - - /// Actions to apply to generate completions. - #[arg(short = 'A')] actions: Vec, - - /// File glob pattern to be expanded to generate completions. - #[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")] glob_pattern: Option, - - /// List of words that will be considered as completions. - #[arg(short = 'W', allow_hyphen_values = true)] word_list: Option, - - /// Name of a shell function to invoke to generate completions. - #[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")] function_name: Option, - - /// Command to execute to generate completions. - #[arg(short = 'C', allow_hyphen_values = true)] command: Option, - - /// Pattern used as filter for completions. - #[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")] filter_pattern: Option, - - /// Prefix pattern used as filter for completions. - #[arg(short = 'P', allow_hyphen_values = true)] prefix: Option, - - /// Suffix pattern used as filter for completions. - #[arg(short = 'S', allow_hyphen_values = true)] suffix: Option, - - /// Complete with valid aliases. - #[arg(short = 'a')] action_alias: bool, - - /// Complete with names of shell builtins. - #[arg(short = 'b')] action_builtin: bool, - - /// Complete with names of executable commands. - #[arg(short = 'c')] action_command: bool, - - /// Complete with directory names. - #[arg(short = 'd')] action_directory: bool, - - /// Complete with names of exported shell variables. - #[arg(short = 'e')] action_exported: bool, - - /// Complete with filenames. - #[arg(short = 'f')] action_file: bool, - - /// Complete with valid user groups. - #[arg(short = 'g')] action_group: bool, - - /// Complete with job specs. - #[arg(short = 'j')] action_job: bool, - - /// Complete with keywords. - #[arg(short = 'k')] action_keyword: bool, - - /// Complete with names of system services. - #[arg(short = 's')] action_service: bool, - - /// Complete with valid usernames. - #[arg(short = 'u')] action_user: bool, - - /// Complete with names of shell variables. - #[arg(short = 'v')] action_variable: bool, } impl CommonCompleteCommandArgs { + fn parser() -> impl bpaf::Parser { + let options = bpaf::short('o') + .help("Options governing the behavior of completions.") + .argument::("OPT") + .many(); + let actions = bpaf::short('A') + .help("Actions to apply to generate completions.") + .argument::("ACTION") + .many(); + let glob_pattern = bpaf::short('G') + .help("File glob pattern to be expanded to generate completions.") + .argument::("GLOB") + .optional(); + let word_list = bpaf::short('W') + .help("List of words that will be considered as completions.") + .argument::("WORD_LIST") + .optional(); + let function_name = bpaf::short('F') + .help("Name of a shell function to invoke to generate completions.") + .argument::("FUNC_NAME") + .optional(); + let command = bpaf::short('C') + .help("Command to execute to generate completions.") + .argument::("COMMAND") + .optional(); + let filter_pattern = bpaf::short('X') + .help("Pattern used as filter for completions.") + .argument::("PATTERN") + .optional(); + let prefix = bpaf::short('P') + .help("Prefix pattern used as filter for completions.") + .argument::("PREFIX") + .optional(); + let suffix = bpaf::short('S') + .help("Suffix pattern used as filter for completions.") + .argument::("SUFFIX") + .optional(); + + let action_alias = bpaf::short('a') + .help("Complete with valid aliases.") + .switch(); + let action_builtin = bpaf::short('b') + .help("Complete with names of shell builtins.") + .switch(); + let action_command = bpaf::short('c') + .help("Complete with names of executable commands.") + .switch(); + let action_directory = bpaf::short('d') + .help("Complete with directory names.") + .switch(); + let action_exported = bpaf::short('e') + .help("Complete with names of exported shell variables.") + .switch(); + let action_file = bpaf::short('f').help("Complete with filenames.").switch(); + let action_group = bpaf::short('g') + .help("Complete with valid user groups.") + .switch(); + let action_job = bpaf::short('j').help("Complete with job specs.").switch(); + let action_keyword = bpaf::short('k').help("Complete with keywords.").switch(); + let action_service = bpaf::short('s') + .help("Complete with names of system services.") + .switch(); + let action_user = bpaf::short('u') + .help("Complete with valid usernames.") + .switch(); + let action_variable = bpaf::short('v') + .help("Complete with names of shell variables.") + .switch(); + + bpaf::construct!(Self { + options, + actions, + glob_pattern, + word_list, + function_name, + command, + filter_pattern, + prefix, + suffix, + action_alias, + action_builtin, + action_command, + action_directory, + action_exported, + action_file, + action_group, + action_job, + action_keyword, + action_service, + action_user, + action_variable, + }) + } + fn create_spec(&self, extglob_enabled: bool) -> completion::Spec { let filter_pattern_excludes; let filter_pattern = if let Some(filter_pattern) = self.filter_pattern.as_ref() { @@ -171,53 +203,153 @@ impl CommonCompleteCommandArgs { } } +/// Returns whether the given argument is one of the value-taking short options +/// whose values are permitted to look like flags. +fn is_value_taking_option(arg: &str) -> bool { + matches!(arg, "-G" | "-W" | "-F" | "-C" | "-X" | "-P" | "-S") +} + +/// Joins flag-looking values onto the value-taking options that precede them +/// (e.g., `-W -foo` becomes `-W=-foo`) since bpaf otherwise rejects separate +/// values that start with `-`. +fn join_flag_looking_values(args: Vec) -> Vec { + let mut joined = Vec::with_capacity(args.len()); + let mut iter = args.into_iter().peekable(); + + while let Some(arg) = iter.next() { + if is_value_taking_option(&arg) && iter.peek().is_some_and(|next| next.starts_with('-')) { + if let Some(next) = iter.next() { + joined.push(format!("{arg}={next}")); + continue; + } + } + + joined.push(arg); + } + + joined +} + +/// Runs the given command's parser against the provided arguments. +/// +// N.B. This mirrors `brush_core::builtins::run_parser`, which is not public. +fn run_parser(args: &[String]) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_parse_failure) +} + +fn render_parse_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + /// Configure programmable command completion. -#[derive(Parser)] pub(crate) struct CompleteCommand { - /// Display registered completion settings. - #[arg(short = 'p')] print: bool, - - /// Remove the completion settings associated with the given command. - #[arg(short = 'r')] remove: bool, - - /// Apply these settings to the default completion scenario. - #[arg(short = 'D')] use_as_default: bool, - - /// Apply these settings to completion of empty lines. - #[arg(short = 'E')] use_for_empty_line: bool, - - /// Apply these settings to completion of the initial word of the input line. - #[arg(short = 'I')] use_for_initial_word: bool, - - #[clap(flatten)] common_args: CommonCompleteCommandArgs, - names: Vec, } impl builtins::Command for CompleteCommand { type Error = brush_core::Error; + /// Overrides the default [`builtins::Command::new`] flow to pre-join + /// flag-looking values onto their value-taking options; see + /// [`join_flag_looking_values`]. + fn new(args: I) -> Result + where + I: IntoIterator, + { + // N.B. The first argument is the command name itself. + let args: Vec = args.into_iter().skip(1).collect(); + run_parser(&join_flag_looking_values(args)) + } + + fn parser() -> impl bpaf::Parser { + let print = bpaf::short('p') + .help("Display registered completion settings.") + .switch(); + let remove = bpaf::short('r') + .help("Remove the completion settings associated with the given command.") + .switch(); + let use_as_default = bpaf::short('D') + .help("Apply these settings to the default completion scenario.") + .switch(); + let use_for_empty_line = bpaf::short('E') + .help("Apply these settings to completion of empty lines.") + .switch(); + let use_for_initial_word = bpaf::short('I') + .help("Apply these settings to completion of the initial word of the input line.") + .switch(); + let common_args = CommonCompleteCommandArgs::parser(); + let names = bpaf::positional::("NAMES") + .help("Names of commands to configure completions for.") + .many(); + + bpaf::construct!(CompleteCommand { + print, + remove, + use_as_default, + use_for_empty_line, + use_for_initial_word, + common_args, + names, + }) + } + + fn about() -> &'static str { + "Configure programmable command completion." + } + + fn synopsis() -> &'static str { + "[-prDEI] [-o OPT]... [-A ACTION]... [NAME]..." + } + async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, ) -> Result { let mut result = ExecutionResult::success(); + // N.B. A leading `--` operand ends the builtin's option section and is + // not part of the command names. + let names: &[String] = { + let leading_markers = self + .names + .iter() + .take_while(|name| name.as_str() == "--") + .count(); + &self.names[leading_markers..] + }; + // If -D, -E, or -I are specified, then any names provided are ignored. if self.use_as_default || self.use_for_empty_line || self.use_for_initial_word - || self.names.is_empty() + || names.is_empty() { self.process_global(&mut context)?; } else { - for name in &self.names { + for name in names { if !self.try_process_for_command(&mut context, name.as_str())? { result = ExecutionResult::general_error(); } @@ -460,9 +592,7 @@ impl CompleteCommand { } /// Generate command completions. -#[derive(Parser)] pub(crate) struct CompGenCommand { - #[clap(flatten)] common_args: CommonCompleteCommandArgs, // N.B. The word can only start with a hyphen if it's after a --. @@ -472,6 +602,33 @@ pub(crate) struct CompGenCommand { impl builtins::Command for CompGenCommand { type Error = brush_core::Error; + /// Overrides the default [`builtins::Command::new`] flow to pre-join + /// flag-looking values onto their value-taking options; see + /// [`join_flag_looking_values`]. + fn new(args: I) -> Result + where + I: IntoIterator, + { + // N.B. The first argument is the command name itself. + let args: Vec = args.into_iter().skip(1).collect(); + run_parser(&join_flag_looking_values(args)) + } + + fn parser() -> impl bpaf::Parser { + let common_args = CommonCompleteCommandArgs::parser(); + let word = bpaf::positional::("WORD").optional(); + + bpaf::construct!(CompGenCommand { common_args, word }) + } + + fn about() -> &'static str { + "Generate command completions." + } + + fn synopsis() -> &'static str { + "[-o OPT]... [-A ACTION]... [WORD]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -526,33 +683,67 @@ impl builtins::Command for CompGenCommand { } /// Set programmable command completion options. -#[derive(Parser)] pub(crate) struct CompOptCommand { - /// Update the default completion settings. - #[arg(short = 'D')] update_default: bool, - - /// Update the completion settings for empty lines. - #[arg(short = 'E')] update_empty: bool, - - /// Update the completion settings for the initial word of the input line. - #[arg(short = 'I')] update_initial_word: bool, - - /// Enable the specified option for selected completion scenarios. - #[arg(short = 'o', value_name = "OPT")] enabled_options: Vec, - #[arg(long = concat!("+o"), hide = true)] disabled_options: Vec, - - /// If specified, scopes updates to completions of the named commands. names: Vec, } impl builtins::Command for CompOptCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let update_default = bpaf::short('D') + .help("Update the default completion settings.") + .switch(); + let update_empty = bpaf::short('E') + .help("Update the completion settings for empty lines.") + .switch(); + let update_initial_word = bpaf::short('I') + .help("Update the completion settings for the initial word of the input line.") + .switch(); + + let enabled_options = bpaf::short('o') + .help("Enable the specified option for selected completion scenarios.") + .argument::("OPT") + .many(); + + // N.B. The value may be adjacent to the tag (`+o OPT`); it cannot be + // expressed as a simple argument parser because of the '+' spelling. + let disabled_options = { + let tag = bpaf::literal("+o"); + let val = bpaf::any("OPT", |opt: CompleteOption| Some(opt)).optional(); + bpaf::construct!(tag, val) + .adjacent() + .many() + .map(|groups| groups.into_iter().filter_map(|((), opt)| opt).collect()) + }; + + let names = bpaf::positional::("NAMES") + .help("If specified, scopes updates to completions of the named commands.") + .many(); + + bpaf::construct!(CompOptCommand { + update_default, + update_empty, + update_initial_word, + enabled_options, + disabled_options, + names, + }) + } + + fn about() -> &'static str { + "Set programmable command completion options." + } + + fn synopsis() -> &'static str { + "[-DEI] [-o OPT]... [+o OPT]... [NAME]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/continue_.rs b/brush-builtins/src/continue_.rs index 8634097a0..6701b2985 100644 --- a/brush-builtins/src/continue_.rs +++ b/brush-builtins/src/continue_.rs @@ -1,18 +1,30 @@ -use clap::Parser; +use bpaf::Parser; use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; /// Continue to the next iteration of a control-flow loop. -#[derive(Parser)] pub(crate) struct ContinueCommand { - /// If specified, indicates which nested loop to continue to the next iteration of. - #[clap(default_value_t = 1)] which_loop: i8, } impl builtins::Command for ContinueCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let which_loop = bpaf::positional::("WHICH_LOOP") + .help("If specified, indicates which nested loop to continue to the next iteration of.") + .fallback(1); + bpaf::construct!(ContinueCommand { which_loop }) + } + + fn about() -> &'static str { + "Continue to the next iteration of a control-flow loop." + } + + fn synopsis() -> &'static str { + "[N]" + } + async fn execute( &self, _context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/declare.rs b/brush-builtins/src/declare.rs index 11ee10427..917316530 100644 --- a/brush-builtins/src/declare.rs +++ b/brush-builtins/src/declare.rs @@ -1,4 +1,3 @@ -use clap::Parser; use itertools::Itertools; use std::{io::Write, sync::LazyLock}; @@ -13,110 +12,31 @@ use brush_core::{ }, }; -crate::minus_or_plus_flag_arg!( - MakeIndexedArrayFlag, - 'a', - "Make the variable an indexed array." -); -crate::minus_or_plus_flag_arg!( - MakeAssociativeArrayFlag, - 'A', - "Make the variable an associative array." -); -crate::minus_or_plus_flag_arg!( - CapitalizeValueOnAssignmentFlag, - 'c', - "Enable capitalize-on-assignment for the variable." -); -crate::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed"); -crate::minus_or_plus_flag_arg!( - LowercaseValueOnAssignmentFlag, - 'l', - "Enable lowercase-on-assignment for the variable." -); -crate::minus_or_plus_flag_arg!( - MakeNameRefFlag, - 'n', - "Mark the variable as a name reference" -); -crate::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only."); -crate::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable."); -crate::minus_or_plus_flag_arg!( - UppercaseValueOnAssignmentFlag, - 'u', - "Enable uppercase-on-assignment for the variable." -); -crate::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export."); - /// Display or update variables and their attributes. -#[derive(Parser)] -#[clap(override_usage = "declare [OPTIONS] [DECLARATIONS]...")] pub(crate) struct DeclareCommand { - /// Constrain to function names or definitions. - #[arg(short = 'f')] function_names_or_defs_only: bool, - - /// Constrain to function names only. - #[arg(short = 'F')] function_names_only: bool, - - /// Create global variable, if applicable. - #[arg(short = 'g')] create_global: bool, - - /// When creating a local variable that shadows another variable of the same name, - /// then initialize it with the contents and attributes of the variable being shadowed. - #[arg(short = 'I')] locals_inherit_from_prev_scope: bool, - - /// Display each item's attributes and values. - #[arg(short = 'p')] print: bool, - // // Attribute options - #[clap(flatten)] // -a - make_indexed_array: MakeIndexedArrayFlag, - #[clap(flatten)] // -A - make_associative_array: MakeAssociativeArrayFlag, - #[clap(flatten)] // -c - capitalize_value_on_assignment: CapitalizeValueOnAssignmentFlag, - #[clap(flatten)] // -i - make_integer: MakeIntegerFlag, - #[clap(flatten)] // -l - lowercase_value_on_assignment: LowercaseValueOnAssignmentFlag, - #[clap(flatten)] // -n - make_nameref: MakeNameRefFlag, - #[clap(flatten)] // -r - make_readonly: MakeReadonlyFlag, - #[clap(flatten)] // -t - make_traced: MakeTracedFlag, - #[clap(flatten)] // -u - uppercase_value_on_assignment: UppercaseValueOnAssignmentFlag, - #[clap(flatten)] // -x - make_exported: MakeExportedFlag, - - // - // Declarations - // - // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait. - #[clap(skip)] + make_indexed_array: Option, + make_associative_array: Option, + capitalize_value_on_assignment: Option, + make_integer: Option, + lowercase_value_on_assignment: Option, + make_nameref: Option, + make_readonly: Option, + make_traced: Option, + uppercase_value_on_assignment: Option, + make_exported: Option, + + // N.B. These are skipped during parsing, but filled in by the + // DeclarationCommand trait. declarations: Vec, } -#[derive(Clone, Copy)] -enum DeclareVerb { - Declare, - Local, - Readonly, -} - -impl builtins::DeclarationCommand for DeclareCommand { - fn set_declarations(&mut self, declarations: Vec) { - self.declarations = declarations; - } -} - impl builtins::Command for DeclareCommand { fn takes_plus_options() -> bool { true @@ -124,6 +44,84 @@ impl builtins::Command for DeclareCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let function_names_or_defs_only = bpaf::short('f') + .help("Constrain to function names or definitions.") + .switch(); + let function_names_only = bpaf::short('F') + .help("Constrain to function names only.") + .switch(); + let create_global = bpaf::short('g') + .help("Create global variable, if applicable.") + .switch(); + let locals_inherit_from_prev_scope = bpaf::short('I') + .help( + "When creating a local variable that shadows another variable of the same name, \ + then initialize it with the contents and attributes of the variable being \ + shadowed.", + ) + .switch(); + let print = bpaf::short('p') + .help("Display each item's attributes and values.") + .switch(); + + let make_indexed_array = + crate::minus_or_plus_flag('a', "+a", "Make the variable an indexed array."); + let make_associative_array = + crate::minus_or_plus_flag('A', "+A", "Make the variable an associative array."); + let capitalize_value_on_assignment = crate::minus_or_plus_flag( + 'c', + "+c", + "Enable capitalize-on-assignment for the variable.", + ); + let make_integer = + crate::minus_or_plus_flag('i', "+i", "Mark the variable as integer-typed"); + let lowercase_value_on_assignment = crate::minus_or_plus_flag( + 'l', + "+l", + "Enable lowercase-on-assignment for the variable.", + ); + let make_nameref = + crate::minus_or_plus_flag('n', "+n", "Mark the variable as a name reference"); + let make_readonly = crate::minus_or_plus_flag('r', "+r", "Mark the variable as read-only."); + let make_traced = crate::minus_or_plus_flag('t', "+t", "Enable tracing for the variable."); + let uppercase_value_on_assignment = crate::minus_or_plus_flag( + 'u', + "+u", + "Enable uppercase-on-assignment for the variable.", + ); + let make_exported = crate::minus_or_plus_flag('x', "+x", "Mark the variable for export."); + + let declarations = bpaf::pure(Vec::new()); + + bpaf::construct!(DeclareCommand { + function_names_or_defs_only, + function_names_only, + create_global, + locals_inherit_from_prev_scope, + print, + make_indexed_array, + make_associative_array, + capitalize_value_on_assignment, + make_integer, + lowercase_value_on_assignment, + make_nameref, + make_readonly, + make_traced, + uppercase_value_on_assignment, + make_exported, + declarations, + }) + } + + fn about() -> &'static str { + "Display or update variables and their attributes." + } + + fn synopsis() -> &'static str { + "[OPTIONS] [DECLARATIONS]..." + } + async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, @@ -174,6 +172,19 @@ impl builtins::Command for DeclareCommand { } } +#[derive(Clone, Copy)] +enum DeclareVerb { + Declare, + Local, + Readonly, +} + +impl builtins::DeclarationCommand for DeclareCommand { + fn set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } +} + impl DeclareCommand { fn try_display_declaration( &self, @@ -444,20 +455,20 @@ impl DeclareCommand { } // Add filters depending on attribute flags. - if let Some(value) = self.make_indexed_array.to_bool() { + if let Some(value) = self.make_indexed_array { filters.push(Box::new(move |(_, v)| { matches!(v.value(), ShellValue::IndexedArray(_)) == value })); } - if let Some(value) = self.make_associative_array.to_bool() { + if let Some(value) = self.make_associative_array { filters.push(Box::new(move |(_, v)| { matches!(v.value(), ShellValue::AssociativeArray(_)) == value })); } - if let Some(value) = self.make_integer.to_bool() { + if let Some(value) = self.make_integer { filters.push(Box::new(move |(_, v)| v.is_treated_as_integer() == value)); } - if let Some(value) = self.capitalize_value_on_assignment.to_bool() { + if let Some(value) = self.capitalize_value_on_assignment { filters.push(Box::new(move |(_, v)| { matches!( v.get_update_transform(), @@ -465,7 +476,7 @@ impl DeclareCommand { ) == value })); } - if let Some(value) = self.lowercase_value_on_assignment.to_bool() { + if let Some(value) = self.lowercase_value_on_assignment { filters.push(Box::new(move |(_, v)| { matches!( v.get_update_transform(), @@ -473,16 +484,16 @@ impl DeclareCommand { ) == value })); } - if let Some(value) = self.make_nameref.to_bool() { + if let Some(value) = self.make_nameref { filters.push(Box::new(move |(_, v)| v.is_treated_as_nameref() == value)); } - if let Some(value) = self.make_readonly.to_bool() { + if let Some(value) = self.make_readonly { filters.push(Box::new(move |(_, v)| v.is_readonly() == value)); } - if let Some(value) = self.make_readonly.to_bool() { + if let Some(value) = self.make_readonly { filters.push(Box::new(move |(_, v)| v.is_trace_enabled() == value)); } - if let Some(value) = self.uppercase_value_on_assignment.to_bool() { + if let Some(value) = self.uppercase_value_on_assignment { filters.push(Box::new(move |(_, v)| { matches!( v.get_update_transform(), @@ -490,7 +501,7 @@ impl DeclareCommand { ) == value })); } - if let Some(value) = self.make_exported.to_bool() { + if let Some(value) = self.make_exported { filters.push(Box::new(move |(_, v)| v.is_exported() == value)); } @@ -562,14 +573,14 @@ impl DeclareCommand { &self, var: &mut ShellVariable, ) -> Result<(), brush_core::Error> { - if let Some(value) = self.make_integer.to_bool() { + if let Some(value) = self.make_integer { if value { var.treat_as_integer(); } else { var.unset_treat_as_integer(); } } - if let Some(value) = self.capitalize_value_on_assignment.to_bool() { + if let Some(value) = self.capitalize_value_on_assignment { if value { var.set_update_transform(ShellVariableUpdateTransform::Capitalize); } else if matches!( @@ -579,7 +590,7 @@ impl DeclareCommand { var.set_update_transform(ShellVariableUpdateTransform::None); } } - if let Some(value) = self.lowercase_value_on_assignment.to_bool() { + if let Some(value) = self.lowercase_value_on_assignment { if value { var.set_update_transform(ShellVariableUpdateTransform::Lowercase); } else if matches!( @@ -589,21 +600,21 @@ impl DeclareCommand { var.set_update_transform(ShellVariableUpdateTransform::None); } } - if let Some(value) = self.make_nameref.to_bool() { + if let Some(value) = self.make_nameref { if value { var.treat_as_nameref(); } else { var.unset_treat_as_nameref(); } } - if let Some(value) = self.make_traced.to_bool() { + if let Some(value) = self.make_traced { if value { var.enable_trace(); } else { var.disable_trace(); } } - if let Some(value) = self.uppercase_value_on_assignment.to_bool() { + if let Some(value) = self.uppercase_value_on_assignment { if value { var.set_update_transform(ShellVariableUpdateTransform::Uppercase); } else if matches!( @@ -613,7 +624,7 @@ impl DeclareCommand { var.set_update_transform(ShellVariableUpdateTransform::None); } } - if let Some(value) = self.make_exported.to_bool() { + if let Some(value) = self.make_exported { if value { var.export(); } else { @@ -631,7 +642,7 @@ impl DeclareCommand { ) -> Result<(), brush_core::Error> { if matches!(verb, DeclareVerb::Readonly) { var.set_readonly(); - } else if let Some(value) = self.make_readonly.to_bool() { + } else if let Some(value) = self.make_readonly { if value { var.set_readonly(); } else { diff --git a/brush-builtins/src/dirs.rs b/brush-builtins/src/dirs.rs index 790310226..b9f7ab3a4 100644 --- a/brush-builtins/src/dirs.rs +++ b/brush-builtins/src/dirs.rs @@ -1,4 +1,4 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionResult, builtins}; @@ -26,22 +26,22 @@ impl From<&DirError> for brush_core::ExecutionExitCode { impl brush_core::BuiltinError for DirError {} /// Manage the current directory stack. -#[derive(Default, Parser)] +#[derive(Default, Bpaf)] pub(crate) struct DirsCommand { /// Clear the directory stack. - #[arg(short = 'c')] + #[bpaf(short('c'))] clear: bool, /// Don't tilde-shorten paths. - #[arg(short = 'l')] + #[bpaf(short('l'))] tilde_long: bool, /// Print one directory per line instead of all on one line. - #[arg(short = 'p')] + #[bpaf(short('p'))] print_one_per_line: bool, /// Print one directory per line with its index. - #[arg(short = 'v')] + #[bpaf(short('v'))] print_one_per_line_with_index: bool, // // TODO(dirs): implement +N and -N @@ -50,6 +50,18 @@ pub(crate) struct DirsCommand { impl builtins::Command for DirsCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + dirs_command() + } + + fn about() -> &'static str { + "Manage the current directory stack." + } + + fn synopsis() -> &'static str { + "[-clpv]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/dot.rs b/brush-builtins/src/dot.rs index 7bf5a4b59..ecc90423b 100644 --- a/brush-builtins/src/dot.rs +++ b/brush-builtins/src/dot.rs @@ -1,26 +1,65 @@ use std::path::Path; -use brush_core::builtins; -use clap::Parser; +use brush_core::{ExecutionExitCode, builtins}; +use std::io::Write; /// Evaluate the provided script in the current shell environment. -#[derive(Parser)] pub(crate) struct DotCommand { /// Path to the script to evaluate. script_path: String, /// Any arguments to be passed as positional parameters to the script. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] script_args: Vec, } impl builtins::Command for DotCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let script_path = bpaf::pure(String::new()); + let script_args = bpaf::pure(Vec::new()); + + bpaf::construct!(DotCommand { + script_path, + script_args, + }) + } + + fn about() -> &'static str { + "Evaluate the provided script in the current shell environment." + } + + fn synopsis() -> &'static str { + "SCRIPT_PATH [ARGS]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, args: Vec) { + let mut iter = args.into_iter(); + if let Some(script_path) = iter.next() { + self.script_path = script_path; + } + self.script_args = iter.collect(); + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { + if self.script_path.is_empty() { + writeln!( + context.stderr(), + "{}: filename argument required", + context.command_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + // TODO(dot): Handle trap inheritance. context .shell diff --git a/brush-builtins/src/echo.rs b/brush-builtins/src/echo.rs index 03da65a5a..e65cc6c95 100644 --- a/brush-builtins/src/echo.rs +++ b/brush-builtins/src/echo.rs @@ -1,44 +1,61 @@ -use clap::Parser; use std::io::Write; use brush_core::{ExecutionResult, builtins, escape}; /// Echo text to standard output. -#[derive(Parser)] -#[clap(disable_help_flag = true, disable_version_flag = true)] pub(crate) struct EchoCommand { /// Suppress the trailing newline from the output. - #[arg(short = 'n')] no_trailing_newline: bool, /// Interpret backslash escapes in the provided text. - #[arg(short = 'e')] interpret_backslash_escapes: bool, /// Do not interpret backslash escapes in the provided text. - #[arg(short = 'E')] no_interpret_backslash_escapes: bool, /// Tokens to echo to standard output. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } impl builtins::Command for EchoCommand { type Error = brush_core::Error; - /// Override the default [`builtins::Command::new`] function to handle clap's limitation related - /// to `--`. See [`builtins::parse_known`] for more information - /// TODO(echo): we can safely remove this after the issue is resolved - fn new(args: I) -> Result - where - I: IntoIterator, - { - let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?; - if let Some(args) = rest_args { - this.args.extend(args); - } - Ok(this) + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let no_trailing_newline = bpaf::short('n') + .help("Suppress the trailing newline from the output.") + .switch(); + let interpret_backslash_escapes = bpaf::short('e') + .help("Interpret backslash escapes in the provided text.") + .switch(); + let no_interpret_backslash_escapes = bpaf::short('E') + .help("Do not interpret backslash escapes in the provided text.") + .switch(); + let args = bpaf::pure(Vec::new()); + + bpaf::construct!(EchoCommand { + no_trailing_newline, + interpret_backslash_escapes, + no_interpret_backslash_escapes, + args, + }) + } + + fn about() -> &'static str { + "Echo text to standard output." + } + + fn synopsis() -> &'static str { + "[-neE] [TOKENS]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, args: Vec) { + self.args = args; } async fn execute( @@ -47,7 +64,7 @@ impl builtins::Command for EchoCommand { ) -> Result { let mut trailing_newline = !self.no_trailing_newline; let mut s; - if self.interpret_backslash_escapes { + if self.interpret_backslash_escapes && !self.no_interpret_backslash_escapes { s = String::new(); for (i, arg) in self.args.iter().enumerate() { if i > 0 { diff --git a/brush-builtins/src/enable.rs b/brush-builtins/src/enable.rs index 48dc7d14f..3d6fdb7ef 100644 --- a/brush-builtins/src/enable.rs +++ b/brush-builtins/src/enable.rs @@ -1,45 +1,57 @@ -use brush_core::ExecutionResult; -use clap::Parser; +use bpaf::Bpaf; use itertools::Itertools; use std::io::Write; -use brush_core::builtins; -use brush_core::error; +use brush_core::{ExecutionResult, builtins, error}; /// Enable, disable, or display built-in commands. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct EnableCommand { /// Print a list of built-in commands. - #[arg(short = 'a')] + #[bpaf(short('a'))] print_list: bool, /// Disables the specified built-in commands. - #[arg(short = 'n')] + #[bpaf(short('n'))] disable: bool, /// Print a list of built-in commands with reusable output. - #[arg(short = 'p')] + #[bpaf(short('p'))] + #[expect(dead_code)] print_reusably: bool, /// Only operate on special built-in commands. - #[arg(short = 's')] + #[bpaf(short('s'))] special_only: bool, /// Path to a shared object from which built-in commands will be loaded. - #[arg(short = 'f', value_name = "PATH")] + #[bpaf(short('f'), argument("PATH"))] shared_object_path: Option, /// Remove the built-in commands loaded from the indicated object path. - #[arg(short = 'd')] + #[bpaf(short('d'))] remove_loaded_builtin: bool, /// Names of built-in commands to operate on. + #[bpaf(positional("NAMES"))] names: Vec, } impl builtins::Command for EnableCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + enable_command() + } + + fn about() -> &'static str { + "Enable, disable, or display built-in commands." + } + + fn synopsis() -> &'static str { + "[-adnps] [-f PATH] [NAMES]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/eval.rs b/brush-builtins/src/eval.rs index 0526fc965..e306a8281 100644 --- a/brush-builtins/src/eval.rs +++ b/brush-builtins/src/eval.rs @@ -1,25 +1,56 @@ use brush_core::{ExecutionResult, builtins}; -use clap::Parser; /// Evaluate the given string as script. -#[derive(Parser)] pub(crate) struct EvalCommand { /// The script to evaluate. - #[clap(allow_hyphen_values = true)] args: Vec, } impl builtins::Command for EvalCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let args = bpaf::pure(Vec::new()); + + bpaf::construct!(EvalCommand { args }) + } + + fn about() -> &'static str { + "Evaluate the given string as script." + } + + fn synopsis() -> &'static str { + "[COMMAND]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, args: Vec) { + self.args = args; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if !self.args.is_empty() { - let args_concatenated = self.args.join(" "); + // N.B. A leading `--` ends eval's (empty) option section and is not + // part of the script to evaluate. + let script = self + .args + .iter() + .skip(usize::from( + self.args.first().map(String::as_str) == Some("--"), + )) + .cloned() + .collect::>() + .join(" "); - tracing::debug!("Applying eval to: {:?}", args_concatenated); + if !script.is_empty() { + tracing::debug!("Applying eval to: {:?}", script); // Our new source context is relative to the current position because we are only // providing the raw string being eval'd. @@ -33,7 +64,7 @@ impl builtins::Command for EvalCommand { // exit, break, continue) should propagate. context .shell - .run_string(args_concatenated, &source_info, &context.params) + .run_string(script, &source_info, &context.params) .await } else { Ok(ExecutionResult::success()) diff --git a/brush-builtins/src/exec.rs b/brush-builtins/src/exec.rs index d3ade049d..08ed5d871 100644 --- a/brush-builtins/src/exec.rs +++ b/brush-builtins/src/exec.rs @@ -1,31 +1,69 @@ -use clap::Parser; +use bpaf::Parser; use std::{borrow::Cow, os::unix::process::CommandExt}; use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands}; /// Exec the provided command. -#[derive(Parser)] pub(crate) struct ExecCommand { /// Pass given name as zeroth argument to command. - #[arg(short = 'a', value_name = "NAME")] name_for_argv0: Option, /// Exec command with an empty environment. - #[arg(short = 'c')] empty_environment: bool, /// Exec command as a login shell. - #[arg(short = 'l')] exec_as_login: bool, /// Command and args. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } impl builtins::Command for ExecCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let name_for_argv0 = bpaf::short('a') + .help("Pass given name as zeroth argument to command.") + .argument::("NAME") + .optional(); + let empty_environment = bpaf::short('c') + .help("Exec command with an empty environment.") + .switch(); + let exec_as_login = bpaf::short('l') + .help("Exec command as a login shell.") + .switch(); + let args = bpaf::pure(Vec::new()); + + bpaf::construct!(ExecCommand { + name_for_argv0, + empty_environment, + exec_as_login, + args, + }) + } + + fn about() -> &'static str { + "Exec the provided command." + } + + fn synopsis() -> &'static str { + "[-acl] [COMMAND [ARG]...]" + } + + fn takes_trailing_args() -> bool { + true + } + + fn value_taking_short_options() -> &'static str { + "a" + } + + fn set_trailing_args(&mut self, args: Vec) { + self.args = args; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/exit.rs b/brush-builtins/src/exit.rs index 31786f3c8..ca5c44dfc 100644 --- a/brush-builtins/src/exit.rs +++ b/brush-builtins/src/exit.rs @@ -1,25 +1,61 @@ -use clap::Parser; +use std::io::Write; -use brush_core::{ExecutionControlFlow, ExecutionResult, builtins}; +use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; /// Exit the shell. -#[derive(Parser)] pub(crate) struct ExitCommand { /// The exit code to return. - #[arg(allow_hyphen_values = true)] - code: Option, + code: Option, } impl builtins::Command for ExitCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let code = bpaf::pure(None); + + bpaf::construct!(ExitCommand { code }) + } + + fn about() -> &'static str { + "Exit the shell." + } + + fn synopsis() -> &'static str { + "[N]" + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, mut args: Vec) { + self.code = if args.is_empty() { + None + } else { + Some(args.remove(0)) + }; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { #[expect(clippy::cast_sign_loss)] - let code_8bit = if let Some(code_32bit) = &self.code { - (code_32bit & 0xFF) as u8 + let code_8bit = if let Some(code) = &self.code { + if let Ok(code_32bit) = code.parse::() { + (code_32bit & 0xFF) as u8 + } else { + writeln!( + context.stderr(), + "{}: {}: numeric argument required", + context.command_name, + code + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } } else { context.shell.last_exit_status() }; diff --git a/brush-builtins/src/export.rs b/brush-builtins/src/export.rs index 5e906bd2b..98b8c7252 100644 --- a/brush-builtins/src/export.rs +++ b/brush-builtins/src/export.rs @@ -1,4 +1,3 @@ -use clap::Parser; use itertools::Itertools; use std::io::Write; @@ -10,25 +9,22 @@ use brush_core::{ }; /// Add or update exported shell variables. -#[derive(Parser)] pub(crate) struct ExportCommand { /// Names are treated as function names. - #[arg(short = 'f')] names_are_functions: bool, /// Un-export the names. - #[arg(short = 'n')] unexport: bool, /// Display all exported names. - #[arg(short = 'p')] + #[expect(dead_code)] display_exported_names: bool, // // Declarations // - // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait. - #[clap(skip)] + // N.B. These are skipped by the parser, but filled in by the + // BuiltinDeclarationCommand trait. declarations: Vec, } @@ -41,6 +37,34 @@ impl builtins::DeclarationCommand for ExportCommand { impl builtins::Command for ExportCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let names_are_functions = bpaf::short('f') + .help("Names are treated as function names.") + .switch(); + let unexport = bpaf::short('n').help("Un-export the names.").switch(); + let display_exported_names = bpaf::short('p') + .help("Display all exported names.") + .switch(); + + // N.B. Declarations are captured separately from options. + let declarations = bpaf::pure(Vec::new()); + + bpaf::construct!(ExportCommand { + names_are_functions, + unexport, + display_exported_names, + declarations, + }) + } + + fn about() -> &'static str { + "Add or update exported shell variables." + } + + fn synopsis() -> &'static str { + "[-fn] [NAME[=VALUE]]..." + } + async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/fc.rs b/brush-builtins/src/fc.rs index 9f4695efd..6ae9c22c5 100644 --- a/brush-builtins/src/fc.rs +++ b/brush-builtins/src/fc.rs @@ -1,42 +1,160 @@ -use brush_core::{ExecutionResult, builtins, error, history}; -use clap::Parser; +use bpaf::Parser; +use std::ffi::OsStr; use std::io::Write; +use brush_core::{ExecutionResult, builtins, error, history}; + /// Process command history list. -#[derive(Parser)] pub(crate) struct FcCommand { /// List commands instead of editing them. - #[arg(short = 'l')] list: bool, /// Suppress line numbers when listing. - #[arg(short = 'n', requires = "list")] no_line_numbers: bool, /// Reverse the order of commands. - #[arg(short = 'r')] reverse: bool, /// Re-execute command after substitution (old=new format). - #[arg(short = 's')] substitute: bool, /// Editor to use (only relevant when not listing or substituting). - #[arg(short = 'e', value_name = "ENAME")] + // N.B. Editor mode is not yet implemented, so this is only surfaced + // through the option parser and help text. + #[cfg_attr(not(test), expect(dead_code))] editor: Option, /// First command in range (number or string prefix). - #[arg(value_name = "FIRST", allow_hyphen_values = true)] first: Option, /// Last command in range (number or string prefix). - #[arg(value_name = "LAST", allow_hyphen_values = true)] last: Option, } impl builtins::Command for FcCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let list = bpaf::short('l') + .help("List commands instead of editing them.") + .switch(); + let no_line_numbers = bpaf::short('n') + .help("Suppress line numbers when listing.") + .switch(); + let reverse = bpaf::short('r') + .help("Reverse the order of commands.") + .switch(); + let substitute = bpaf::short('s') + .help("Re-execute command after substitution (old=new format).") + .switch(); + let editor = bpaf::short('e') + .help("Editor to use (only relevant when not listing or substituting).") + .argument::("ENAME") + .optional(); + let first = bpaf::pure(None); + let last = bpaf::pure(None); + + bpaf::construct!(FcCommand { + list, + no_line_numbers, + reverse, + substitute, + editor, + first, + last, + }) + } + + fn about() -> &'static str { + "Process command history list." + } + + fn synopsis() -> &'static str { + "[-lnrs] [-e ENAME] [FIRST [LAST]]" + } + + fn takes_trailing_args() -> bool { + true + } + + fn value_taking_short_options() -> &'static str { + "e" + } + + // N.B. Overrides the default [`builtins::Command::new`] so that negative + // history indices (e.g., `fc -l -3`) are captured as operands rather than + // being rejected as unknown flags. + fn new(args: I) -> Result + where + I: IntoIterator, + { + let mut options = Vec::new(); + let mut trailing = Vec::new(); + + // N.B. The first argument is the command name itself. + let mut iter = args.into_iter().skip(1); + let mut pending_value = false; + while let Some(arg) = iter.next() { + if pending_value { + // This token is the value of a preceding value-taking option. + options.push(arg); + pending_value = false; + continue; + } + + if arg == "--" { + trailing.extend(iter); + break; + } + + if !arg.starts_with('-') || arg == "-" { + // An operand; everything from here on is captured verbatim. + trailing.push(arg); + trailing.extend(iter); + break; + } + + if is_negative_number(&arg) { + // A negative history index (an operand). + trailing.push(arg); + continue; + } + + if let Some(group) = arg.strip_prefix('-').filter(|g| !g.starts_with('-')) { + let chars: Vec = group.chars().collect(); + for (j, c) in chars.iter().enumerate() { + match c { + 'e' => { + pending_value = j == chars.len() - 1; + break; + } + 'l' | 'n' | 'r' | 's' => {} + _ => break, + } + } + } + + options.push(arg); + } + + let mut command = run_bpaf_parser::(&options)?; + command.set_trailing_args(trailing); + + Ok(command) + } + + fn set_trailing_args(&mut self, args: Vec) { + let mut iter = args.into_iter(); + if let Some(first) = iter.next() { + self.first = Some(first); + } + if let Some(last) = iter.next() { + self.last = Some(last); + } + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -53,6 +171,14 @@ impl builtins::Command for FcCommand { } } +/// Returns whether the given argument looks like a negative number; these are +/// treated as operands since they specify offsets relative to the end of +/// history rather than options. +fn is_negative_number(arg: &str) -> bool { + arg.strip_prefix('-') + .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) +} + impl FcCommand { fn do_list( &self, @@ -299,3 +425,71 @@ impl FcCommand { fn effective_history_count(history: &history::History) -> usize { history.count().saturating_sub(1) } + +fn run_bpaf_parser( + args: &[String], +) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_bpaf_failure) +} + +fn render_bpaf_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use brush_core::builtins::Command as _; + + fn new_from(args: &[&str]) -> Result { + FcCommand::new(std::iter::once("fc".to_string()).chain(args.iter().map(|s| s.to_string()))) + } + + #[test] + fn test_negative_indices_as_operands() -> anyhow::Result<()> { + let cmd = new_from(&["-l", "-3", "-1"])?; + assert!(cmd.list); + assert_eq!(cmd.first.as_deref(), Some("-3")); + assert_eq!(cmd.last.as_deref(), Some("-1")); + + Ok(()) + } + + #[test] + fn test_options_and_operands() -> anyhow::Result<()> { + let cmd = new_from(&["-e", "vim", "10", "20"])?; + assert_eq!(cmd.editor.as_deref(), Some("vim")); + assert_eq!(cmd.first.as_deref(), Some("10")); + assert_eq!(cmd.last.as_deref(), Some("20")); + + Ok(()) + } + + #[test] + fn test_substitution_spec() -> anyhow::Result<()> { + let cmd = new_from(&["-s", "ech=echo"])?; + assert!(cmd.substitute); + assert_eq!(cmd.first.as_deref(), Some("ech=echo")); + assert_eq!(cmd.last, None); + + Ok(()) + } +} diff --git a/brush-builtins/src/fg.rs b/brush-builtins/src/fg.rs index ec5796fe3..da307ed16 100644 --- a/brush-builtins/src/fg.rs +++ b/brush-builtins/src/fg.rs @@ -1,18 +1,32 @@ -use clap::Parser; +use bpaf::Bpaf; + use std::io::Write; use brush_core::{ExecutionResult, builtins, jobs, sys}; /// Move a specified job to the foreground. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct FgCommand { /// Job spec for the job to move to the foreground; if not specified, the current job is moved. + #[bpaf(positional("JOB_SPEC"))] job_spec: Option, } impl builtins::Command for FgCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + fg_command() + } + + fn about() -> &'static str { + "Move a specified job to the foreground." + } + + fn synopsis() -> &'static str { + "[JOB_SPEC]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/getopts.rs b/brush-builtins/src/getopts.rs index e57247fa5..194022856 100644 --- a/brush-builtins/src/getopts.rs +++ b/brush-builtins/src/getopts.rs @@ -1,12 +1,11 @@ use std::{collections::HashMap, io::Write}; -use clap::Parser; - -use brush_core::{ExecutionResult, builtins, env, variables}; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, env, variables}; /// Parse command options. -#[derive(Parser)] pub(crate) struct GetOptsCommand { + /// Whether fewer than the two required operands were provided. + missing_operands: bool, /// Specification for options options_string: String, @@ -14,7 +13,6 @@ pub(crate) struct GetOptsCommand { variable_name: String, /// Arguments to parse - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } @@ -83,24 +81,61 @@ fn parse_option_spec(spec: &str) -> OptionSpec { impl builtins::Command for GetOptsCommand { type Error = brush_core::Error; - /// Override the default [`builtins::Command::new`] function to handle clap's limitation related - /// to `--`. See [`builtins::parse_known`] for more information - /// TODO(command): we can safely remove this after the issue is resolved - fn new(args: I) -> Result - where - I: IntoIterator, - { - let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?; - if let Some(args) = rest_args { - this.args.extend(args); + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. The two required + // operands are validated in `execute`. + let options_string = bpaf::pure(String::new()); + let variable_name = bpaf::pure(String::new()); + let args = bpaf::pure(Vec::new()); + let missing_operands = bpaf::pure(false); + + bpaf::construct!(GetOptsCommand { + options_string, + variable_name, + args, + missing_operands, + }) + } + + fn about() -> &'static str { + "Parse command options." + } + + fn synopsis() -> &'static str { + "OPTSTRING NAME [ARGS]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, args: Vec) { + self.missing_operands = args.len() < 2; + + let mut iter = args.into_iter(); + if let Some(options_string) = iter.next() { + self.options_string = options_string; } - Ok(this) + if let Some(variable_name) = iter.next() { + self.variable_name = variable_name; + } + self.args = iter.collect(); } async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, ) -> Result { + if self.missing_operands { + writeln!( + context.stderr(), + "{}: two arguments required", + context.command_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + // Validate the target variable name. if !env::valid_variable_name(&self.variable_name) { writeln!( diff --git a/brush-builtins/src/hash.rs b/brush-builtins/src/hash.rs index b8dda4289..eee550427 100644 --- a/brush-builtins/src/hash.rs +++ b/brush-builtins/src/hash.rs @@ -1,37 +1,50 @@ -use clap::Parser; +use bpaf::Bpaf; use std::{io::Write, path::PathBuf}; use brush_core::{ExecutionResult, builtins}; -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct HashCommand { /// Remove entries associated with the given names. - #[arg(short = 'd')] + #[bpaf(short('d'))] remove: bool, /// Display paths in a format usable for input. - #[arg(short = 'l')] + #[bpaf(short('l'))] display_as_usable_input: bool, /// The path to associate with the names. - #[arg(short = 'p', value_name = "PATH")] + #[bpaf(short('p'), argument("PATH"))] path_to_use: Option, /// Remove all entries. - #[arg(short = 'r')] + #[bpaf(short('r'))] remove_all: bool, /// Display the paths associated with the names. - #[arg(short = 't')] + #[bpaf(short('t'))] display_paths: bool, /// Names to process. + #[bpaf(positional("NAMES"))] names: Vec, } impl builtins::Command for HashCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + hash_command() + } + + fn about() -> &'static str { + "Remember or display program locations." + } + + fn synopsis() -> &'static str { + "[-dlrt] [-p PATH] [NAMES]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/help.rs b/brush-builtins/src/help.rs index bde2bdaed..a381d01fb 100644 --- a/brush-builtins/src/help.rs +++ b/brush-builtins/src/help.rs @@ -1,30 +1,49 @@ +use bpaf::Parser; use brush_core::{ExecutionResult, builtins}; -use clap::Parser; use itertools::Itertools; use std::io::Write; /// Display command help. -#[derive(Parser)] pub(crate) struct HelpCommand { - /// Display a short description for the commands. - #[arg(short = 'd')] short_description: bool, - - /// Display a man-style page of documentation for the commands. - #[arg(short = 'm')] man_page_style: bool, - - /// Display a short usage summary for the commands. - #[arg(short = 's')] short_usage: bool, - - /// Patterns of topics to display help for. topic_patterns: Vec, } impl builtins::Command for HelpCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let short_description = bpaf::short('d') + .help("Display a short description for the commands.") + .switch(); + let man_page_style = bpaf::short('m') + .help("Display a man-style page of documentation for the commands.") + .switch(); + let short_usage = bpaf::short('s') + .help("Display a short usage summary for the commands.") + .switch(); + let topic_patterns = bpaf::positional::("PATTERNS") + .help("Patterns of topics to display help for.") + .many(); + + bpaf::construct!(HelpCommand { + short_description, + man_page_style, + short_usage, + topic_patterns, + }) + } + + fn about() -> &'static str { + "Display command help." + } + + fn synopsis() -> &'static str { + "[-dms] [PATTERNS]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/history.rs b/brush-builtins/src/history.rs index 8d703ae69..ce119ca19 100644 --- a/brush-builtins/src/history.rs +++ b/brush-builtins/src/history.rs @@ -1,50 +1,42 @@ -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; -use clap::Parser; +use bpaf::Parser; +use std::ffi::OsStr; use std::{ io::Write, path::{Path, PathBuf}, }; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; + /// Query or manipulate the shell's command history. // TODO(history): Evaluate which of the options conflict with each other. -#[derive(Parser)] #[expect(clippy::option_option)] pub(crate) struct HistoryCommand { /// Clears all history. - #[arg(short = 'c')] clear_history: bool, /// Deletes the history entry at the given offset. Positive offsets are relative to the /// beginning of the history, while negative offsets are relative to the end of the history. - #[arg(short = 'd', value_name = "OFFSET")] delete_offset: Option, /// Appends the history from the current session to the history file. - #[arg(short = 'a', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")] append_session_to_file: Option>, /// Appends any remaining history from the history file to the current session. - #[arg(short = 'n', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")] append_rest_of_file_to_session: Option>, /// Appends the history from the history file to the current session. - #[arg(short = 'r', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")] append_file_to_session: Option>, /// Replaces the history file with the current session history. - #[arg(short = 'w', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")] write_session_to_file: Option>, /// History-expands positional arguments and displays them. - #[arg(short = 'p', num_args = 0.., value_name = "ARG")] expand_args: Option>, /// Appends positional arguments as an entry in the current session. - #[arg(short = 's', num_args = 0.., value_name = "ARG")] append_args_to_session: Option>, /// Arguments. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } @@ -56,6 +48,106 @@ struct HistoryConfig { impl builtins::Command for HistoryCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let clear_history = bpaf::short('c').help("Clears all history.").switch(); + let delete_offset = bpaf::short('d') + .help( + "Deletes the history entry at the given offset. Positive offsets are \ + relative to the beginning of the history, while negative offsets are \ + relative to the end of the history.", + ) + .argument::("OFFSET") + .optional(); + + let append_session_to_file = hist_file_option( + 'a', + "Appends the history from the current session to the history file.", + ); + let append_rest_of_file_to_session = hist_file_option( + 'n', + "Appends any remaining history from the history file to the current session.", + ); + let append_file_to_session = hist_file_option( + 'r', + "Appends the history from the history file to the current session.", + ); + let write_session_to_file = hist_file_option( + 'w', + "Replaces the history file with the current session history.", + ); + let expand_args = bpaf::short('p') + .help("History-expands positional arguments and displays them.") + .switch() + .map(|present| present.then(Vec::new)); + let append_args_to_session = bpaf::short('s') + .help("Appends positional arguments as an entry in the current session.") + .switch() + .map(|present| present.then(Vec::new)); + let args = bpaf::pure(Vec::new()); + + bpaf::construct!(HistoryCommand { + clear_history, + delete_offset, + append_session_to_file, + append_rest_of_file_to_session, + append_file_to_session, + write_session_to_file, + expand_args, + append_args_to_session, + args, + }) + } + + fn about() -> &'static str { + "Query or manipulate the shell's command history." + } + + fn synopsis() -> &'static str { + "[-c] [-d OFFSET] [-anrw] [-ps] [ARGS]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn value_taking_short_options() -> &'static str { + "danrw" + } + + // N.B. Overrides the default [`builtins::Command::new`] so that flag-looking + // values for `-d` and `-anrw` (e.g., `history -d -3`, a negative offset) get + // joined into `-d=-3`; bpaf otherwise rejects separate flag-shaped values. + fn new(args: I) -> Result + where + I: IntoIterator, + { + let mut args: Vec = args.into_iter().collect(); + + // N.B. The first argument is the command name itself. + if !args.is_empty() { + args.remove(0); + } + join_tokens_taking_values(&mut args, Self::value_taking_short_options()); + + let (options, trailing) = + builtins::split_option_section(&args, Self::value_taking_short_options(), &[]); + + let mut command = run_bpaf_parser::(&options)?; + command.set_trailing_args(trailing); + + Ok(command) + } + + fn set_trailing_args(&mut self, args: Vec) { + if self.append_args_to_session.is_some() { + self.append_args_to_session = Some(args); + } else { + self.args = args; + } + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -221,26 +313,132 @@ fn get_effective_history_file_path<'a>( option.map(Path::new).or(default_history_file_path) } +/// Merges `-X` tokens followed by a flag-looking value token into `-X=` +/// so that bpaf accepts values that would otherwise be rejected as flags; +/// e.g., negative offsets. +fn join_tokens_taking_values(args: &mut Vec, shorts: &str) { + let mut i = 0; + while i < args.len() { + let arg = args[i].clone(); + + if arg == "--" { + break; + } + + let takes_value = arg.len() == 2 + && arg.starts_with('-') + && arg.chars().nth(1).is_some_and(|c| shorts.contains(c)); + + if takes_value { + if let Some(next) = args.get(i + 1) { + if next.starts_with('-') && next != "-" && next != "--" { + args[i] = format!("{arg}={next}"); + args.remove(i + 1); + } + } + } + + i += 1; + } +} + +/// Builds a parser for one of the `-a`/`-n`/`-r`/`-w` options, each of which +/// takes an optional `HIST_FILE` value. +/// +// N.B. Alternation of "with value" and "bare" forms wrapped in a final +// `optional` distinguishes between the option being absent and +// present-without-a-value. +fn hist_file_option( + short_char: char, + help: &'static str, +) -> impl bpaf::Parser>> { + let with_value = bpaf::short(short_char) + .help(help) + .argument::("HIST_FILE") + .map(Some); + let bare = bpaf::short(short_char).req_flag(()).map(|()| None); + + bpaf::construct!([with_value, bare]).optional() +} + +fn run_bpaf_parser( + args: &[String], +) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_bpaf_failure) +} + +fn render_bpaf_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + #[cfg(test)] mod tests { use super::*; use anyhow::Result; + use brush_core::builtins::Command as _; use pretty_assertions::{assert_eq, assert_matches}; + fn new_from(args: &[&str]) -> Result { + HistoryCommand::new( + std::iter::once("history".to_string()).chain(args.iter().map(|s| s.to_string())), + ) + } + #[test] fn test_parse_dash_a() -> Result<()> { - let cmd = HistoryCommand::try_parse_from(["history", "5"])?; - assert_matches!(cmd.append_session_to_file, None); + assert_matches!(new_from(&["5"])?.append_session_to_file, None); - let cmd = HistoryCommand::try_parse_from(["history", "-a"])?; - assert_matches!(cmd.append_session_to_file, Some(None)); + assert_matches!(new_from(&["-a"])?.append_session_to_file, Some(None)); - let cmd = HistoryCommand::try_parse_from(["history", "-a", "token"])?; assert_eq!( - cmd.append_session_to_file, + new_from(&["-a", "token"])?.append_session_to_file, Some(Some(String::from("token"))) ); Ok(()) } + + #[test] + fn test_parse_negative_delete_offset() -> Result<()> { + assert_eq!(new_from(&["-d", "-3"])?.delete_offset, Some(-3)); + + Ok(()) + } + + #[test] + fn test_parse_append_args_to_session() -> Result<()> { + let cmd = new_from(&["-s", "echo", "hello", "world"])?; + assert_matches!(cmd.append_args_to_session, Some(_)); + assert_eq!( + cmd.append_args_to_session.unwrap(), + ["echo", "hello", "world"] + ); + + Ok(()) + } + + #[test] + fn test_parse_max_entries() -> Result<()> { + let cmd = new_from(&["5"])?; + assert_eq!(cmd.args, ["5"]); + + Ok(()) + } } diff --git a/brush-builtins/src/jobs.rs b/brush-builtins/src/jobs.rs index c3686a96a..4926f6202 100644 --- a/brush-builtins/src/jobs.rs +++ b/brush-builtins/src/jobs.rs @@ -1,39 +1,53 @@ -use clap::Parser; +use bpaf::Bpaf; + use std::io::Write; use brush_core::{ExecutionResult, builtins, error, jobs}; /// Manage jobs. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct JobsCommand { /// Also show process IDs. - #[arg(short = 'l')] + #[bpaf(short('l'))] also_show_pids: bool, /// List only jobs that have changed status since the last notification. - #[arg(short = 'n')] + #[bpaf(short('n'))] list_changed_only: bool, /// Show only process IDs. - #[arg(short = 'p')] + #[bpaf(short('p'))] show_pids_only: bool, /// Show only running jobs. - #[arg(short = 'r')] + #[bpaf(short('r'))] running_jobs_only: bool, /// Show only stopped jobs. - #[arg(short = 's')] + #[bpaf(short('s'))] stopped_jobs_only: bool, /// Job specs to list. // TODO(jobs): Add -x option + #[bpaf(positional("JOB_SPECS"))] job_specs: Vec, } impl builtins::Command for JobsCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + jobs_command() + } + + fn about() -> &'static str { + "Manage jobs." + } + + fn synopsis() -> &'static str { + "[-lnprs] [JOB_SPECS]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/kill.rs b/brush-builtins/src/kill.rs index f69b9fa82..50ae79871 100644 --- a/brush-builtins/src/kill.rs +++ b/brush-builtins/src/kill.rs @@ -1,34 +1,143 @@ -use clap::Parser; -use std::io::Write; +use bpaf::Parser; +use std::{ffi::OsStr, io::Write}; use brush_core::traps::TrapSignal; use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys}; /// Signal a job or process. -#[derive(Parser)] pub(crate) struct KillCommand { /// Name of the signal to send. - #[arg(short = 's', value_name = "SIG_NAME")] signal_name: Option, /// Number of the signal to send. - #[arg(short = 'n', value_name = "SIG_NUM")] signal_number: Option, - // - // TODO(kill): implement -sigspec syntax /// List known signal names. - #[arg(short = 'l', short_alias = 'L')] list_signals: bool, - // Interpretation of these depends on whether -l is present. - #[arg(allow_hyphen_values = true)] + /// Remaining arguments; may contain pids/job specs as well as `-sigspec` + /// style options, whose interpretation depends on whether `-l` is present. args: Vec, } impl builtins::Command for KillCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let signal_name = bpaf::short('s') + .help("Name of the signal to send.") + .argument::("SIG_NAME") + .optional(); + let signal_number = bpaf::short('n') + .help("Number of the signal to send.") + .argument::("SIG_NUM") + .optional(); + // N.B. `-L` is a hidden alias for `-l`, matching clap's short_alias. + let list_signals = bpaf::short('l') + .short('L') + .help("List known signal names.") + .req_flag(()) + .map(|(): ()| Some(true)) + .fallback(None) + .map(|v: Option| v.is_some()); + let args = bpaf::pure(Vec::new()); + + bpaf::construct!(KillCommand { + signal_name, + signal_number, + list_signals, + args, + }) + } + + fn about() -> &'static str { + "Signal a job or process." + } + + fn synopsis() -> &'static str { + "[-s SIG_NAME | -n SIG_NUM | -lL] [PID_OR_JOB_SPEC]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn value_taking_short_options() -> &'static str { + "sn" + } + + /// N.B. Overrides the default [`builtins::Command::new`] because `-sigspec` + /// style options (e.g., `kill -9` or `kill -TERM`) look like flags but must + /// be captured verbatim alongside pids and job specs so that `execute` can + /// interpret them. + fn new(args: I) -> Result + where + I: IntoIterator, + { + let mut options = Vec::new(); + let mut trailing = Vec::new(); + + // N.B. The first argument is the command name itself. + let mut iter = args.into_iter().skip(1); + let mut pending_value = false; + while let Some(arg) = iter.next() { + if pending_value { + // This token is the value of a preceding value-taking option. + options.push(arg); + pending_value = false; + continue; + } + + if arg == "--" { + trailing.extend(iter); + break; + } + + if !arg.starts_with('-') || arg == "-" { + // An operand; everything from here on is captured verbatim. + trailing.push(arg); + trailing.extend(iter); + break; + } + + if arg.starts_with('-') + && !arg.starts_with("--") + && arg.chars().nth(1).is_none_or(|c| !"snlL".contains(c)) + { + // A `-sigspec` style token (e.g., `-9` or `-TERM`). + trailing.push(arg); + continue; + } + + if let Some(group) = arg.strip_prefix('-').filter(|g| !g.starts_with('-')) { + let chars: Vec = group.chars().collect(); + for (j, c) in chars.iter().enumerate() { + match c { + 's' | 'n' => { + pending_value = j == chars.len() - 1; + break; + } + 'l' | 'L' => {} + _ => break, + } + } + } + + options.push(arg); + } + + let mut command = run_bpaf_parser::(&options)?; + command.set_trailing_args(trailing); + + Ok(command) + } + + fn set_trailing_args(&mut self, args: Vec) { + self.args = args; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -177,3 +286,53 @@ fn print_signals( Ok(exit_code) } + +fn run_bpaf_parser( + args: &[String], +) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_bpaf_failure) +} + +fn render_bpaf_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use brush_core::builtins::Command as _; + + #[test] + fn parse_s_with_name() -> anyhow::Result<()> { + let cmd = KillCommand::new(["kill", "-s", "TERM", "123"].iter().map(|s| s.to_string()))?; + assert_eq!(cmd.signal_name.as_deref(), Some("TERM")); + assert_eq!(cmd.args, ["123"]); + Ok(()) + } + + #[test] + fn parse_dash_sigspec() -> anyhow::Result<()> { + let cmd = KillCommand::new(["kill", "-USR1", "123"].iter().map(|s| s.to_string()))?; + assert!(cmd.signal_name.is_none()); + assert_eq!(cmd.args, ["-USR1", "123"]); + Ok(()) + } +} diff --git a/brush-builtins/src/let_.rs b/brush-builtins/src/let_.rs index b87a705ec..e73618caa 100644 --- a/brush-builtins/src/let_.rs +++ b/brush-builtins/src/let_.rs @@ -1,19 +1,40 @@ -use clap::Parser; use std::io::Write; use brush_core::{ExecutionExitCode, ExecutionResult, arithmetic::Evaluatable, builtins}; /// Evaluate arithmetic expressions. -#[derive(Parser)] pub(crate) struct LetCommand { /// Arithmetic expressions to evaluate. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] exprs: Vec, } impl builtins::Command for LetCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. + let exprs = bpaf::pure(Vec::new()); + + bpaf::construct!(LetCommand { exprs }) + } + + fn about() -> &'static str { + "Evaluate arithmetic expressions." + } + + fn synopsis() -> &'static str { + "[EXPRESSION]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn set_trailing_args(&mut self, args: Vec) { + self.exprs = args; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/lib.rs b/brush-builtins/src/lib.rs index ac36b3cd9..30b4c438c 100644 --- a/brush-builtins/src/lib.rs +++ b/brush-builtins/src/lib.rs @@ -123,44 +123,33 @@ mod unimp; pub use builder::ShellBuilderExt; pub use factory::{BuiltinSet, default_builtins}; -/// Macro to define a struct that represents a shell built-in flag argument that can be -/// enabled or disabled by specifying an option with a leading '+' or '-' character. +/// Returns a parser for a shell built-in flag argument that can be enabled or +/// disabled by specifying an option with a leading '-' or '+' character, +/// respectively (e.g., `-x` and `+x`). +/// +/// The parser produces `None` when neither form is provided, `Some(true)` when +/// the `-x` form is provided, and `Some(false)` when the `+x` form is provided. /// /// # Arguments /// -/// - `$struct_name` - The identifier to be used for the struct to define. /// - `$flag_char` - The character to use as the flag. +/// - `plus_form` - The literal plus-style form of the flag; e.g., `"+x"`. /// - `$desc` - The string description of the flag. -#[macro_export] -macro_rules! minus_or_plus_flag_arg { - ($struct_name:ident, $flag_char:literal, $desc:literal) => { - #[derive(clap::Parser)] - pub(crate) struct $struct_name { - #[arg(short = $flag_char, name = concat!(stringify!($struct_name), "_enable"), action = clap::ArgAction::SetTrue, help = $desc)] - _enable: bool, - #[arg(long = concat!("+", $flag_char), name = concat!(stringify!($struct_name), "_disable"), action = clap::ArgAction::SetTrue, hide = true)] - _disable: bool, - } - - impl From<$struct_name> for Option { - fn from(value: $struct_name) -> Self { - value.to_bool() - } - } +pub(crate) fn minus_or_plus_flag( + flag_char: char, + plus_form: &'static str, + desc: &'static str, +) -> impl bpaf::Parser> { + use bpaf::Parser; - impl $struct_name { - #[allow(dead_code, reason = "may not be used in all macro instantiations")] - pub const fn is_some(&self) -> bool { - self._enable || self._disable - } + let enable = bpaf::short(flag_char) + .help(desc) + .switch() + .map(|enabled| enabled.then_some(true)); + let disable = bpaf::literal(plus_form) + .help("Disables the flag.") + .hide() + .map(|(): ()| Some(false)); - pub const fn to_bool(&self) -> Option { - match (self._enable, self._disable) { - (true, false) => Some(true), - (false, true) => Some(false), - _ => None, - } - } - } - }; + bpaf::construct!([enable, disable]).fallback(None) } diff --git a/brush-builtins/src/mapfile.rs b/brush-builtins/src/mapfile.rs index ba5505350..326ed742f 100644 --- a/brush-builtins/src/mapfile.rs +++ b/brush-builtins/src/mapfile.rs @@ -1,52 +1,119 @@ +use bpaf::Parser; +use std::ffi::OsStr; use std::io::{Read, Write}; -use clap::Parser; - use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables}; /// Read lines from standard input into an indexed array variable. -#[derive(Parser)] pub(crate) struct MapFileCommand { /// Delimiter to use (defaults to newline). - #[arg(short = 'd')] delimiter: Option, /// Maximum number of entries to read (0 means no limit). - #[arg(short = 'n', default_value_t = 0)] max_count: i64, /// Index into array at which to start assignment. - #[arg(short = 'O', allow_hyphen_values = true)] origin: Option, /// Number of initial entries to skip. - #[arg(short = 's', default_value_t = 0, value_parser = clap::value_parser!(i64).range(0..))] skip_count: i64, /// Whether or not to remove the delimiter from each read line. - #[arg(short = 't')] remove_delimiter: bool, /// File descriptor to read from (defaults to stdin). - #[arg(short = 'u', default_value_t = 0)] fd: brush_core::ShellFd, /// Name of function to call for each group of lines. - #[arg(short = 'C')] callback: Option, /// Number of lines to pass the callback for each group. - #[arg(short = 'c', default_value_t = 5000, value_parser = clap::value_parser!(i64).range(1..))] callback_group_size: i64, /// Name of array to read into. - #[arg(default_value = "MAPFILE")] array_var_name: String, } impl builtins::Command for MapFileCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let delimiter = bpaf::short('d') + .help("Delimiter to use (defaults to newline).") + .argument::("DELIM") + .optional(); + let max_count = bpaf::short('n') + .help("Maximum number of entries to read (0 means no limit).") + .argument::("COUNT") + .fallback(0); + let origin = bpaf::short('O') + .help("Index into array at which to start assignment.") + .argument::("ORIGIN") + .optional(); + let skip_count = bpaf::short('s') + .help("Number of initial entries to skip.") + .argument::("COUNT") + .guard(|v| *v >= 0, "must be >= 0") + .fallback(0); + let remove_delimiter = bpaf::short('t') + .help("Whether or not to remove the delimiter from each read line.") + .switch(); + let fd = bpaf::short('u') + .help("File descriptor to read from (defaults to stdin).") + .argument::("FD") + .fallback(0); + let callback = bpaf::short('C') + .help("Name of function to call for each group of lines.") + .argument::("CALLBACK") + .optional(); + let callback_group_size = bpaf::short('c') + .help("Number of lines to pass the callback for each group.") + .argument::("COUNT") + .guard(|v| *v >= 1, "must be >= 1") + .fallback(5000); + let array_var_name = bpaf::positional::("ARRAY_VAR_NAME") + .help("Name of array to read into.") + .fallback(String::from("MAPFILE")); + + bpaf::construct!(MapFileCommand { + delimiter, + max_count, + origin, + skip_count, + remove_delimiter, + fd, + callback, + callback_group_size, + array_var_name, + }) + } + + fn about() -> &'static str { + "Read lines from standard input into an indexed array variable." + } + + fn synopsis() -> &'static str { + "[-d DELIM] [-n COUNT] [-O ORIGIN] [-s COUNT] [-t] [-u FD] [-C CALLBACK] [-c COUNT] [ARRAY_VAR_NAME]" + } + + // N.B. Overrides the default [`builtins::Command::new`] so that a flag-looking + // value for `-O` (e.g., `mapfile -O -3`, a negative array origin) gets joined + // into `-O=-3`; bpaf otherwise rejects separate flag-shaped values. + fn new(args: I) -> Result + where + I: IntoIterator, + { + let mut args: Vec = args.into_iter().collect(); + + // N.B. The first argument is the command name itself. + if !args.is_empty() { + args.remove(0); + } + join_tokens_taking_values(&mut args, "O"); + + run_bpaf_parser::(&args) + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -192,3 +259,107 @@ fn setup_terminal_settings( Ok(mode) } + +/// Merges `-X` tokens followed by a flag-looking value token into `-X=` +/// so that bpaf accepts values that would otherwise be rejected as flags; +/// e.g., negative numbers. +fn join_tokens_taking_values(args: &mut Vec, shorts: &str) { + let mut i = 0; + while i < args.len() { + let arg = args[i].clone(); + + if arg == "--" { + break; + } + + let takes_value = arg.len() == 2 + && arg.starts_with('-') + && arg.chars().nth(1).is_some_and(|c| shorts.contains(c)); + + if takes_value { + if let Some(next) = args.get(i + 1) { + if next.starts_with('-') && next != "-" && next != "--" { + args[i] = format!("{arg}={next}"); + args.remove(i + 1); + } + } + } + + i += 1; + } +} + +fn run_bpaf_parser( + args: &[String], +) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_bpaf_failure) +} + +fn render_bpaf_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use brush_core::builtins::Command as _; + + fn new_from(args: &[&str]) -> Result { + MapFileCommand::new( + std::iter::once("mapfile".to_string()).chain(args.iter().map(|s| s.to_string())), + ) + } + + #[test] + fn test_defaults() -> anyhow::Result<()> { + let cmd = new_from(&[])?; + assert_eq!(cmd.max_count, 0); + assert_eq!(cmd.skip_count, 0); + assert_eq!(cmd.fd, 0); + assert_eq!(cmd.callback_group_size, 5000); + assert_eq!(cmd.array_var_name, "MAPFILE"); + assert_eq!(cmd.origin, None); + Ok(()) + } + + #[test] + fn test_negative_origin_separate_token() -> anyhow::Result<()> { + let cmd = new_from(&["-O", "-3"])?; + assert_eq!(cmd.origin, Some(-3)); + Ok(()) + } + + #[test] + fn test_options_with_array_name() -> anyhow::Result<()> { + let cmd = new_from(&["-t", "-u", "1", "-s", "2", "-n", "10", "myarray"])?; + assert!(cmd.remove_delimiter); + assert_eq!(cmd.fd, 1); + assert_eq!(cmd.skip_count, 2); + assert_eq!(cmd.max_count, 10); + assert_eq!(cmd.array_var_name, "myarray"); + Ok(()) + } + + #[test] + fn test_invalid_skip_count_rejected() { + assert!(new_from(&["-s", "-1"]).is_err()); + } +} diff --git a/brush-builtins/src/popd.rs b/brush-builtins/src/popd.rs index 3ed150bdf..07e3ac4d2 100644 --- a/brush-builtins/src/popd.rs +++ b/brush-builtins/src/popd.rs @@ -1,12 +1,12 @@ -use clap::Parser; +use bpaf::Bpaf; use brush_core::{ExecutionResult, builtins}; /// Pop a path from the current directory stack. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct PopdCommand { /// Pop the path without changing the current working directory. - #[clap(short = 'n')] + #[bpaf(short('n'))] no_directory_change: bool, // // TODO(popd): implement +N and -N @@ -15,6 +15,18 @@ pub(crate) struct PopdCommand { impl builtins::Command for PopdCommand { type Error = crate::dirs::DirError; + fn parser() -> impl bpaf::Parser { + popd_command() + } + + fn about() -> &'static str { + "Pop a path from the current directory stack." + } + + fn synopsis() -> &'static str { + "[-n]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/printf.rs b/brush-builtins/src/printf.rs index f5a527bb3..329d7500c 100644 --- a/brush-builtins/src/printf.rs +++ b/brush-builtins/src/printf.rs @@ -1,38 +1,86 @@ -use clap::Parser; +use bpaf::Parser; use std::{ffi::OsString, io::Write, ops::ControlFlow}; use uucore::format; -use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion}; +use brush_core::{ + Error, ErrorKind, ExecutionExitCode, ExecutionResult, builtins, escape, expansion, +}; /// Format a string. -#[derive(Parser)] -#[clap(disable_help_flag = true, disable_version_flag = true)] pub(crate) struct PrintfCommand { /// If specified, the output of the command is assigned to this variable. - #[arg(short = 'v')] output_variable: Option, /// Format string + arguments to the format string. - /// - /// N.B. We intentionally do *not* enable `allow_hyphen_values` here. Doing so would - /// cause an attached short-option value such as `-va` (i.e. `-v a`) to be misparsed as - /// a positional argument. With it disabled, a format string that genuinely needs to - /// start with a hyphen must be preceded by `--`, matching other shells' behavior. - #[arg(trailing_var_arg = true, required = true)] format_and_args: Vec, } impl builtins::Command for PrintfCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Only the leading options are parsed here; all remaining tokens + // are captured verbatim via `takes_trailing_args`. A format string that + // genuinely needs to start with a hyphen must be preceded by `--`, + // matching other shells' behavior. + let output_variable = bpaf::short('v') + .help("If specified, the output of the command is assigned to this variable.") + .argument::("VAR") + .optional(); + let format_and_args = bpaf::pure(Vec::new()); + + bpaf::construct!(PrintfCommand { + output_variable, + format_and_args, + }) + } + + fn about() -> &'static str { + "Format a string." + } + + fn synopsis() -> &'static str { + "[-v VAR] FORMAT [ARGUMENT]..." + } + + fn takes_trailing_args() -> bool { + true + } + + fn value_taking_short_options() -> &'static str { + "v" + } + + fn set_trailing_args(&mut self, args: Vec) { + self.format_and_args = args; + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { + // N.B. A leading `--` ends printf's (empty) option section; the format + // starts after it, even if that format begins with a hyphen. + let format_and_args: &[String] = + if self.format_and_args.first().map(String::as_str) == Some("--") { + &self.format_and_args[1..] + } else { + &self.format_and_args + }; + + if format_and_args.is_empty() { + writeln!( + context.stderr(), + "{}: format string required", + context.command_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + if let Some(variable_name) = &self.output_variable { // Format to a u8 vector. let mut result: Vec = vec![]; - format(self.format_and_args.as_slice(), &mut result)?; + format(format_and_args, &mut result)?; // Convert to a string. let result_str = String::from_utf8(result).map_err(|_| { @@ -48,7 +96,7 @@ impl builtins::Command for PrintfCommand { ) .await?; } else { - format(self.format_and_args.as_slice(), context.stdout())?; + format(format_and_args, context.stdout())?; context.stdout().flush()?; } @@ -64,8 +112,8 @@ fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_co [fmt, arg] if fmt == "~%q" => format_special_case_for_percent_q(Some("~"), arg, writer), // Handle format string with arguments using uucore [fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer), - // Handle case with no format string (we shouldn't be able to get here since clap will - // fail parsing when the format string is missing) + // Handle case with no format string (we shouldn't be able to get here since parsing + // fails when the format string is missing) [] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()), } } diff --git a/brush-builtins/src/pushd.rs b/brush-builtins/src/pushd.rs index 279bdc3cc..263fd82c1 100644 --- a/brush-builtins/src/pushd.rs +++ b/brush-builtins/src/pushd.rs @@ -1,15 +1,16 @@ -use clap::Parser; +use bpaf::Bpaf; use brush_core::{ExecutionResult, builtins}; /// Push a path onto the current directory stack. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct PushdCommand { /// Push the path without changing the current working directory. - #[clap(short = 'n')] + #[bpaf(short('n'))] no_directory_change: bool, /// Directory to push on the directory stack. + #[bpaf(positional("DIR"))] dir: String, // // TODO(pushd): implement +N and -N @@ -18,6 +19,18 @@ pub(crate) struct PushdCommand { impl builtins::Command for PushdCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + pushd_command() + } + + fn about() -> &'static str { + "Push a path onto the current directory stack." + } + + fn synopsis() -> &'static str { + "[-n] [DIR]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/pwd.rs b/brush-builtins/src/pwd.rs index 09f9cadc5..357874dc1 100644 --- a/brush-builtins/src/pwd.rs +++ b/brush-builtins/src/pwd.rs @@ -1,29 +1,88 @@ use brush_core::{ExecutionResult, builtins}; -use clap::Parser; use std::{borrow::Cow, io::Write, path::Path}; /// Display the current working directory. -#[derive(Parser)] +#[derive(Clone)] pub(crate) struct PwdCommand { - /// Print the physical directory without any symlinks. - #[arg(short = 'P', overrides_with = "allow_symlinks")] - physical: bool, - - /// Print $PWD if it names the current working directory. - #[arg(short = 'L', overrides_with = "physical")] - allow_symlinks: bool, + /// Whether an explicit physical/logical mode was requested; `Some(true)` + /// means physical (`-P`) and `Some(false)` means logical (`-L`). When both + /// are provided, the last one on the command line wins. + mode: Option, } impl builtins::Command for PwdCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // N.B. Options are interpreted manually in [`Self::new`] because their + // combined forms depend on ordering (`pwd -L -P` vs `pwd -P -L`). + let mode = bpaf::pure(None); + bpaf::construct!(PwdCommand { mode }) + } + + fn about() -> &'static str { + "Display the current working directory." + } + + fn synopsis() -> &'static str { + "[-LP]" + } + + fn new(args: I) -> Result + where + I: IntoIterator, + { + let mut args: Vec = args.into_iter().collect(); + + // N.B. The first argument is the command name itself. + if !args.is_empty() { + args.remove(0); + } + + let mut mode: Option = None; + let mut terminated = false; + + for arg in args { + if !terminated && arg == "--" { + terminated = true; + continue; + } + + if !terminated { + if let Some(group) = arg + .strip_prefix('-') + .filter(|g| !g.is_empty() && g.chars().all(|c| c == 'L' || c == 'P')) + { + if let Some(c) = group.chars().last() { + mode = Some(c == 'P'); + } + continue; + } + + if arg.starts_with('-') && arg != "-" { + return Err(builtins::BuiltinArgParseError { + message: String::from("pwd: invalid option\nUsage: pwd [-LP]"), + help_request: false, + }); + } + } + + return Err(builtins::BuiltinArgParseError { + message: String::from("pwd: too many arguments"), + help_request: false, + }); + } + + Ok(Self { mode }) + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { let mut cwd: Cow<'_, Path> = context.shell.working_dir().into(); - let should_canonicalize = self.physical + let should_canonicalize = self.mode == Some(true) || context .shell .options() @@ -38,3 +97,58 @@ impl builtins::Command for PwdCommand { Ok(ExecutionResult::success()) } } + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use brush_core::builtins::Command as _; + + #[test] + fn parse_modes() { + assert_eq!( + PwdCommand::new(std::iter::once("pwd".to_string())) + .unwrap() + .mode, + None + ); + assert_eq!( + PwdCommand::new(["pwd", "-L"].iter().map(|s| s.to_string())) + .unwrap() + .mode, + Some(false) + ); + assert_eq!( + PwdCommand::new(["pwd", "-P"].iter().map(|s| s.to_string())) + .unwrap() + .mode, + Some(true) + ); + + // Last one wins. + assert_eq!( + PwdCommand::new(["pwd", "-L", "-P"].iter().map(|s| s.to_string())) + .unwrap() + .mode, + Some(true) + ); + assert_eq!( + PwdCommand::new(["pwd", "-P", "-L"].iter().map(|s| s.to_string())) + .unwrap() + .mode, + Some(false) + ); + assert_eq!( + PwdCommand::new(["pwd", "-LP"].iter().map(|s| s.to_string())) + .unwrap() + .mode, + Some(true) + ); + assert_eq!( + PwdCommand::new(["pwd", "-PL"].iter().map(|s| s.to_string())) + .unwrap() + .mode, + Some(false) + ); + } +} diff --git a/brush-builtins/src/read.rs b/brush-builtins/src/read.rs index 56809c60d..8817d37f8 100644 --- a/brush-builtins/src/read.rs +++ b/brush-builtins/src/read.rs @@ -1,6 +1,7 @@ -use clap::Parser; +use bpaf::Parser; use itertools::Itertools; use std::collections::VecDeque; +use std::ffi::OsStr; use std::time::{Duration, Instant}; use brush_core::{ErrorKind, builtins, env, error, variables}; @@ -23,53 +24,41 @@ const DEFAULT_DELIMITER: char = '\n'; const NUL_DELIMITER: char = '\0'; /// Parse standard input. -#[derive(Parser)] pub(crate) struct ReadCommand { /// Optionally, name of an array variable to receive read words /// of input. - #[clap(short = 'a', value_name = "VAR_NAME")] array_variable: Option, /// Optionally, a delimiter to use other than a newline character. - #[clap(short = 'd')] delimiter: Option, /// Use readline-like input. - #[clap(short = 'e')] use_readline: bool, /// Provide text to use as initial input for readline. - #[clap(short = 'i', value_name = "STR")] initial_text: Option, /// Read only the first N characters or until a specified /// delimiter is reached, whichever happens first. - #[clap(short = 'n', value_name = "COUNT")] return_after_n_chars: Option, /// Read exactly N characters, ignoring any specified delimiter. - #[clap(short = 'N', value_name = "COUNT")] return_after_n_chars_no_delimiter: Option, /// Prompt to display before reading. - #[clap(short = 'p')] prompt: Option, /// Read input in raw mode; no escape sequences. - #[clap(short = 'r')] raw_mode: bool, /// Do not echo input. - #[clap(short = 's')] silent: bool, /// Specify timeout in seconds; fail if the timeout elapses before /// input is completed. - #[clap(short = 't', value_name = "SECONDS", allow_hyphen_values = true)] timeout_in_seconds: Option, /// File descriptor to read from instead of stdin. - #[clap(short = 'u', name = "FD")] fd_num_to_read: Option, /// Optionally, names of variables to receive read input. @@ -79,6 +68,96 @@ pub(crate) struct ReadCommand { impl builtins::Command for ReadCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let array_variable = bpaf::short('a') + .help("Optionally, name of an array variable to receive read words of input.") + .argument::("VAR_NAME") + .optional(); + let delimiter = bpaf::short('d') + .help("Optionally, a delimiter to use other than a newline character.") + .argument::("DELIM") + .optional(); + let use_readline = bpaf::short('e').help("Use readline-like input.").switch(); + let initial_text = bpaf::short('i') + .help("Provide text to use as initial input for readline.") + .argument::("STR") + .optional(); + let return_after_n_chars = bpaf::short('n') + .help( + "Read only the first N characters or until a specified delimiter is \ + reached, whichever happens first.", + ) + .argument::("COUNT") + .optional(); + let return_after_n_chars_no_delimiter = bpaf::short('N') + .help("Read exactly N characters, ignoring any specified delimiter.") + .argument::("COUNT") + .optional(); + let prompt = bpaf::short('p') + .help("Prompt to display before reading.") + .argument::("PROMPT") + .optional(); + let raw_mode = bpaf::short('r') + .help("Read input in raw mode; no escape sequences.") + .switch(); + let silent = bpaf::short('s').help("Do not echo input.").switch(); + let timeout_in_seconds = bpaf::short('t') + .help( + "Specify timeout in seconds; fail if the timeout elapses before \ + input is completed.", + ) + .argument::("SECONDS") + .optional(); + let fd_num_to_read = bpaf::short('u') + .help("File descriptor to read from instead of stdin.") + .argument::("FD") + .optional(); + let variable_names = bpaf::positional::("VAR_NAMES") + .help("Optionally, names of variables to receive read input.") + .many(); + + bpaf::construct!(ReadCommand { + array_variable, + delimiter, + use_readline, + initial_text, + return_after_n_chars, + return_after_n_chars_no_delimiter, + prompt, + raw_mode, + silent, + timeout_in_seconds, + fd_num_to_read, + variable_names, + }) + } + + fn about() -> &'static str { + "Parse standard input." + } + + fn synopsis() -> &'static str { + "[-a VAR_NAME] [-d DELIM] [-e] [-i STR] [-n COUNT] [-N COUNT] [-p PROMPT] [-rs] [-t SECONDS] [-u FD] [VAR_NAMES]..." + } + + // N.B. Overrides the default [`builtins::Command::new`] so that a flag-looking + // value for `-t` (e.g., `read -t -0.5`, a negative timeout) gets joined into + // `-t=-0.5`; bpaf otherwise rejects separate flag-shaped values. + fn new(args: I) -> Result + where + I: IntoIterator, + { + let mut args: Vec = args.into_iter().collect(); + + // N.B. The first argument is the command name itself. + if !args.is_empty() { + args.remove(0); + } + join_tokens_taking_values(&mut args, "t"); + + run_bpaf_parser::(&args) + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -685,12 +764,99 @@ fn split_line_by_ifs(ifs: &str, line: &str, max_fields: Option) -> VecDeq fields } +/// Merges `-X` tokens followed by a flag-looking value token into `-X=` +/// so that bpaf accepts values that would otherwise be rejected as flags; +/// e.g., negative timeouts. +fn join_tokens_taking_values(args: &mut Vec, shorts: &str) { + let mut i = 0; + while i < args.len() { + let arg = args[i].clone(); + + if arg == "--" { + break; + } + + let takes_value = arg.len() == 2 + && arg.starts_with('-') + && arg.chars().nth(1).is_some_and(|c| shorts.contains(c)); + + if takes_value { + if let Some(next) = args.get(i + 1) { + if next.starts_with('-') && next != "-" && next != "--" { + args[i] = format!("{arg}={next}"); + args.remove(i + 1); + } + } + } + + i += 1; + } +} + +fn run_bpaf_parser( + args: &[String], +) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_bpaf_failure) +} + +fn render_bpaf_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + #[cfg(test)] +#[expect(clippy::panic_in_result_fn)] mod tests { + use brush_core::builtins::Command as _; use itertools::assert_equal; use super::*; + #[test] + fn test_parse_negative_timeout() -> anyhow::Result<()> { + let cmd = ReadCommand::new(["read", "-t", "-0.5"].iter().map(|s| s.to_string()))?; + assert_eq!(cmd.timeout_in_seconds, Some(-0.5)); + + let cmd = ReadCommand::new(["read", "-t=-0.5"].iter().map(|s| s.to_string()))?; + assert_eq!(cmd.timeout_in_seconds, Some(-0.5)); + + Ok(()) + } + + #[test] + fn test_parse_options_and_vars() -> anyhow::Result<()> { + let cmd = ReadCommand::new( + [ + "read", "-a", "myarray", "-r", "-s", "-u", "3", "first", "rest", + ] + .iter() + .map(|s| s.to_string()), + )?; + assert_eq!(cmd.array_variable.as_deref(), Some("myarray")); + assert!(cmd.raw_mode); + assert!(cmd.silent); + assert_eq!(cmd.fd_num_to_read, Some(3)); + assert_eq!(cmd.variable_names, ["first", "rest"]); + + Ok(()) + } + // ==================== split_line_by_ifs tests ==================== #[test] diff --git a/brush-builtins/src/return_.rs b/brush-builtins/src/return_.rs index f6040a56d..acca8a357 100644 --- a/brush-builtins/src/return_.rs +++ b/brush-builtins/src/return_.rs @@ -1,18 +1,31 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; /// Return from the current function. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct ReturnCommand { /// The exit code to return. + #[bpaf(positional("CODE"))] code: Option, } impl builtins::Command for ReturnCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + return_command() + } + + fn about() -> &'static str { + "Return from the current function." + } + + fn synopsis() -> &'static str { + "[CODE]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/set.rs b/brush-builtins/src/set.rs index 95cf752ee..e024d8431 100644 --- a/brush-builtins/src/set.rs +++ b/brush-builtins/src/set.rs @@ -1,213 +1,254 @@ +use bpaf::Parser; use std::collections::HashMap; +use std::ffi::OsStr; use std::io::Write; -use clap::Parser; use itertools::Itertools; use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables}; -crate::minus_or_plus_flag_arg!( - ExportVariablesOnModification, - 'a', - "Export variables on modification" -); -crate::minus_or_plus_flag_arg!( - NotifyJobTerminationImmediately, - 'b', - "Notify job termination immediately" -); -crate::minus_or_plus_flag_arg!( - ExitOnNonzeroCommandExit, - 'e', - "Exit on nonzero command exit" -); -crate::minus_or_plus_flag_arg!(DisableFilenameGlobbing, 'f', "Disable filename globbing"); -crate::minus_or_plus_flag_arg!(RememberCommandLocations, 'h', "Remember command locations"); -crate::minus_or_plus_flag_arg!( - PlaceAllAssignmentArgsInCommandEnv, - 'k', - "Place all assignment args in command environment" -); -crate::minus_or_plus_flag_arg!(EnableJobControl, 'm', "Enable job control"); -crate::minus_or_plus_flag_arg!(DoNotExecuteCommands, 'n', "Do not execute commands"); -crate::minus_or_plus_flag_arg!(RealEffectiveUidMismatch, 'p', "Real effective UID mismatch"); -crate::minus_or_plus_flag_arg!(ExitAfterOneCommand, 't', "Exit after one command"); -crate::minus_or_plus_flag_arg!( - TreatUnsetVariablesAsError, - 'u', - "Treat unset variables as error" -); -crate::minus_or_plus_flag_arg!(PrintShellInputLines, 'v', "Print shell input lines"); -crate::minus_or_plus_flag_arg!( - PrintCommandsAndArguments, - 'x', - "Print commands and arguments" -); -crate::minus_or_plus_flag_arg!(PerformBraceExpansion, 'B', "Perform brace expansion"); -crate::minus_or_plus_flag_arg!( - DisallowOverwritingRegularFilesViaOutputRedirection, - 'C', - "Disallow overwriting regular files via output redirection" -); -crate::minus_or_plus_flag_arg!( - ShellFunctionsInheritErrTrap, - 'E', - "Shell functions inherit ERR trap" -); -crate::minus_or_plus_flag_arg!( - EnableBangStyleHistorySubstitution, - 'H', - "Enable bang style history substitution" -); -crate::minus_or_plus_flag_arg!( - DoNotResolveSymlinksWhenChangingDir, - 'P', - "Do not resolve symlinks when changing dir" -); -crate::minus_or_plus_flag_arg!( - ShellFunctionsInheritDebugAndReturnTraps, - 'T', - "Shell functions inherit DEBUG and RETURN traps" -); - -#[derive(clap::Parser)] +/// Tri-state capture of a `set -o`/`+o` style option: absent, present with no +/// value (list all), or present with a value. pub(crate) struct SetOption { - #[arg(short = 'o', name = "setopt_enable", num_args=0..=1, value_name = "OPT")] enable: Option>, - #[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)] disable: Option>, } +/// Returns a parser capturing repeated occurrences of a named-option flag +/// (e.g., `-o OPT`) into the same tri-state shape used by [`SetOption`]. +fn named_option_section

(flag: P) -> impl bpaf::Parser>> +where + P: bpaf::Parser<()> + 'static, +{ + let value = bpaf::any("OPT", |s: String| { + if s.starts_with('-') || s.starts_with('+') { + None + } else { + Some(s) + } + }) + .optional(); + + let occurrences = bpaf::construct!(flag, value).adjacent().many(); + + occurrences.map(|occurrences: Vec<((), Option)>| { + (!occurrences.is_empty()).then(|| { + occurrences + .into_iter() + .filter_map(|((), opt)| opt) + .collect::>() + }) + }) +} + /// Manage set-based shell options. -#[derive(Parser)] -#[clap(disable_help_flag = true)] pub(crate) struct SetCommand { - /// Display help for this command. - #[clap(long, action = clap::ArgAction::HelpLong)] - help: Option, - - #[clap(flatten)] - export_variables_on_modification: ExportVariablesOnModification, - #[clap(flatten)] - notify_job_termination_immediately: NotifyJobTerminationImmediately, - #[clap(flatten)] - exit_on_nonzero_command_exit: ExitOnNonzeroCommandExit, - #[clap(flatten)] - disable_filename_globbing: DisableFilenameGlobbing, - #[clap(flatten)] - remember_command_locations: RememberCommandLocations, - #[clap(flatten)] - place_all_assignment_args_in_command_env: PlaceAllAssignmentArgsInCommandEnv, - #[clap(flatten)] - enable_job_control: EnableJobControl, - #[clap(flatten)] - do_not_execute_commands: DoNotExecuteCommands, - #[clap(flatten)] - real_effective_uid_mismatch: RealEffectiveUidMismatch, - #[clap(flatten)] - exit_after_one_command: ExitAfterOneCommand, - #[clap(flatten)] - treat_unset_variables_as_error: TreatUnsetVariablesAsError, - #[clap(flatten)] - print_shell_input_lines: PrintShellInputLines, - #[clap(flatten)] - print_commands_and_arguments: PrintCommandsAndArguments, - #[clap(flatten)] - perform_brace_expansion: PerformBraceExpansion, - #[clap(flatten)] - disallow_overwriting_regular_files_via_output_redirection: - DisallowOverwritingRegularFilesViaOutputRedirection, - #[clap(flatten)] - shell_functions_inherit_err_trap: ShellFunctionsInheritErrTrap, - #[clap(flatten)] - enable_bang_style_history_substitution: EnableBangStyleHistorySubstitution, - #[clap(flatten)] - do_not_resolve_symlinks_when_changing_dir: DoNotResolveSymlinksWhenChangingDir, - #[clap(flatten)] - shell_functions_inherit_debug_and_return_traps: ShellFunctionsInheritDebugAndReturnTraps, - - #[clap(flatten)] - set_option: SetOption, + export_variables_on_modification: Option, + notify_job_termination_immediately: Option, + exit_on_nonzero_command_exit: Option, + disable_filename_globbing: Option, + remember_command_locations: Option, + place_all_assignment_args_in_command_env: Option, + enable_job_control: Option, + do_not_execute_commands: Option, + real_effective_uid_mismatch: Option, + exit_after_one_command: Option, + treat_unset_variables_as_error: Option, + print_shell_input_lines: Option, + print_commands_and_arguments: Option, + perform_brace_expansion: Option, + disallow_overwriting_regular_files_via_output_redirection: Option, + shell_functions_inherit_err_trap: Option, + enable_bang_style_history_substitution: Option, + do_not_resolve_symlinks_when_changing_dir: Option, + shell_functions_inherit_debug_and_return_traps: Option, - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + set_option: SetOption, positional_args: Vec, + double_dash_seen: bool, } impl builtins::Command for SetCommand { + type Error = brush_core::Error; + + fn parser() -> impl bpaf::Parser { + let export_variables_on_modification = + crate::minus_or_plus_flag('a', "+a", "Export variables on modification"); + let notify_job_termination_immediately = + crate::minus_or_plus_flag('b', "+b", "Notify job termination immediately"); + let exit_on_nonzero_command_exit = + crate::minus_or_plus_flag('e', "+e", "Exit on nonzero command exit"); + let disable_filename_globbing = + crate::minus_or_plus_flag('f', "+f", "Disable filename globbing"); + let remember_command_locations = + crate::minus_or_plus_flag('h', "+h", "Remember command locations"); + let place_all_assignment_args_in_command_env = crate::minus_or_plus_flag( + 'k', + "+k", + "Place all assignment args in command environment", + ); + let enable_job_control = crate::minus_or_plus_flag('m', "+m", "Enable job control"); + let do_not_execute_commands = + crate::minus_or_plus_flag('n', "+n", "Do not execute commands"); + let real_effective_uid_mismatch = + crate::minus_or_plus_flag('p', "+p", "Real effective UID mismatch"); + let exit_after_one_command = crate::minus_or_plus_flag('t', "+t", "Exit after one command"); + let treat_unset_variables_as_error = + crate::minus_or_plus_flag('u', "+u", "Treat unset variables as error"); + let print_shell_input_lines = + crate::minus_or_plus_flag('v', "+v", "Print shell input lines"); + let print_commands_and_arguments = + crate::minus_or_plus_flag('x', "+x", "Print commands and arguments"); + let perform_brace_expansion = + crate::minus_or_plus_flag('B', "+B", "Perform brace expansion"); + let disallow_overwriting_regular_files_via_output_redirection = crate::minus_or_plus_flag( + 'C', + "+C", + "Disallow overwriting regular files via output redirection", + ); + let shell_functions_inherit_err_trap = + crate::minus_or_plus_flag('E', "+E", "Shell functions inherit ERR trap"); + let enable_bang_style_history_substitution = + crate::minus_or_plus_flag('H', "+H", "Enable bang style history substitution"); + let do_not_resolve_symlinks_when_changing_dir = + crate::minus_or_plus_flag('P', "+P", "Do not resolve symlinks when changing dir"); + let shell_functions_inherit_debug_and_return_traps = + crate::minus_or_plus_flag('T', "+T", "Shell functions inherit DEBUG and RETURN traps"); + + let set_option = { + let enable = named_option_section( + bpaf::short('o') + .help("Specify a named option; without OPT, lists all named options.") + .req_flag(()), + ); + let disable = named_option_section(bpaf::literal("+o")); + + bpaf::construct!(SetOption { enable, disable }) + }; + + // N.B. Trailing arguments are captured verbatim via `takes_trailing_args`. + let positional_args = bpaf::pure(Vec::new()); + let double_dash_seen = bpaf::pure(false); + + bpaf::construct!(SetCommand { + export_variables_on_modification, + notify_job_termination_immediately, + exit_on_nonzero_command_exit, + disable_filename_globbing, + remember_command_locations, + place_all_assignment_args_in_command_env, + enable_job_control, + do_not_execute_commands, + real_effective_uid_mismatch, + exit_after_one_command, + treat_unset_variables_as_error, + print_shell_input_lines, + print_commands_and_arguments, + perform_brace_expansion, + disallow_overwriting_regular_files_via_output_redirection, + shell_functions_inherit_err_trap, + enable_bang_style_history_substitution, + do_not_resolve_symlinks_when_changing_dir, + shell_functions_inherit_debug_and_return_traps, + set_option, + positional_args, + double_dash_seen, + }) + } + + fn about() -> &'static str { + "Manage set-based shell options and positional parameters." + } + + fn synopsis() -> &'static str { + "[-efhkmnpstuvxBCHEPT] [-o OPT] [+OPT] [--] [ARGS]..." + } + fn takes_plus_options() -> bool { true } - /// Override the default [`builtins::Command::new`] function to handle clap's limitation related - /// to `--`. See [`builtins::parse_known`] for more information - /// TODO(set): we can safely remove this after the issue is resolved - fn new(args: I) -> Result + fn takes_trailing_args() -> bool { + true + } + + fn value_taking_short_options() -> &'static str { + "o" + } + + /// Overrides the default [`builtins::Command::new`] flow so that the presence + /// of a bare `--` terminator can be recorded: the central option-section + /// splitter drops `--` before bpaf ever sees it, yet `set --` must still + /// clear the shell's positional parameters. + fn new(args: I) -> Result where I: IntoIterator, { - // - // TODO(set): This is getting pretty messy; we need to see how to avoid this -- handling - // from leaking into too many commands' custom parsing. - // - - // Apply the same workaround from the default implementation of Command::new to handle '+' - // args. - let mut updated_args = vec![]; - let mut now_parsing_positional_args = false; - let mut next_arg_is_option_value = false; - for (i, arg) in args.into_iter().enumerate() { - if now_parsing_positional_args || next_arg_is_option_value { - updated_args.push(arg); - - next_arg_is_option_value = false; - continue; - } + let mut args: Vec = args.into_iter().collect(); - if arg == "-" || arg == "--" || (i > 0 && !arg.starts_with(['-', '+'])) { - now_parsing_positional_args = true; - } + // N.B. The first argument is the command name itself. + if !args.is_empty() { + args.remove(0); + } - if let Some(plus_options) = arg.strip_prefix("+") { - next_arg_is_option_value = plus_options.ends_with('o'); - for c in plus_options.chars() { - updated_args.push(format!("--+{c}")); + let double_dash_seen = args.iter().any(|arg| arg == "--"); + + // Mirror the central flow: expand '+'-style option groups, split off the + // trailing section of verbatim operands, then parse the leading options. + let mut expanded = Vec::with_capacity(args.len()); + for arg in args { + match arg.strip_prefix('+').filter(|group| !group.is_empty()) { + Some(group) if !group.starts_with('+') && !group.contains('=') => { + expanded.extend(group.chars().map(|c| format!("+{c}"))); } - } else { - next_arg_is_option_value = arg.starts_with('-') && arg.ends_with('o'); - updated_args.push(arg); + _ => expanded.extend(expand_dash_o_group(&arg)), } } - let (mut this, rest_args) = brush_core::builtins::try_parse_known::(updated_args)?; - if let Some(args) = rest_args { - this.positional_args.extend(args); - } - Ok(this) + let (options, trailing) = + builtins::split_option_section(&expanded, Self::value_taking_short_options(), &[]); + + let os_args: Vec<&OsStr> = options.iter().map(OsStr::new).collect(); + let mut command = Self::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_parse_failure)?; + + command.set_trailing_args(trailing); + command.double_dash_seen = double_dash_seen; + + Ok(command) } - type Error = brush_core::Error; + fn set_trailing_args(&mut self, args: Vec) { + self.positional_args = args; + } #[expect(clippy::too_many_lines)] - #[allow(clippy::useless_let_if_seq)] async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { let mut result = ExecutionResult::success(); + #[expect( + clippy::useless_let_if_seq, + reason = "each option block conditionally marks that an option was seen" + )] let mut saw_option = false; - if let Some(value) = self.print_commands_and_arguments.to_bool() { - context.shell.options_mut().print_commands_and_arguments = value; + if self.print_commands_and_arguments.is_some() { + context.shell.options_mut().print_commands_and_arguments = + self.print_commands_and_arguments.unwrap_or_default(); saw_option = true; } - if let Some(value) = self.export_variables_on_modification.to_bool() { + if let Some(value) = self.export_variables_on_modification { context.shell.options_mut().export_variables_on_modification = value; saw_option = true; } - if let Some(value) = self.notify_job_termination_immediately.to_bool() { + if let Some(value) = self.notify_job_termination_immediately { context .shell .options_mut() @@ -215,22 +256,22 @@ impl builtins::Command for SetCommand { saw_option = true; } - if let Some(value) = self.exit_on_nonzero_command_exit.to_bool() { + if let Some(value) = self.exit_on_nonzero_command_exit { context.shell.options_mut().exit_on_nonzero_command_exit = value; saw_option = true; } - if let Some(value) = self.disable_filename_globbing.to_bool() { + if let Some(value) = self.disable_filename_globbing { context.shell.options_mut().disable_filename_globbing = value; saw_option = true; } - if let Some(value) = self.remember_command_locations.to_bool() { + if let Some(value) = self.remember_command_locations { context.shell.options_mut().remember_command_locations = value; saw_option = true; } - if let Some(value) = self.place_all_assignment_args_in_command_env.to_bool() { + if let Some(value) = self.place_all_assignment_args_in_command_env { context .shell .options_mut() @@ -238,50 +279,42 @@ impl builtins::Command for SetCommand { saw_option = true; } - if let Some(value) = self.enable_job_control.to_bool() { + if let Some(value) = self.enable_job_control { context.shell.options_mut().enable_job_control = value; saw_option = true; } - if let Some(value) = self.do_not_execute_commands.to_bool() { + if let Some(value) = self.do_not_execute_commands { context.shell.options_mut().do_not_execute_commands = value; saw_option = true; } - if let Some(value) = self.real_effective_uid_mismatch.to_bool() { + if let Some(value) = self.real_effective_uid_mismatch { context.shell.options_mut().real_effective_uid_mismatch = value; saw_option = true; } - if let Some(value) = self.exit_after_one_command.to_bool() { + if let Some(value) = self.exit_after_one_command { context.shell.options_mut().exit_after_one_command = value; saw_option = true; } - if let Some(value) = self.treat_unset_variables_as_error.to_bool() { + if let Some(value) = self.treat_unset_variables_as_error { context.shell.options_mut().treat_unset_variables_as_error = value; saw_option = true; } - if let Some(value) = self.print_shell_input_lines.to_bool() { + if let Some(value) = self.print_shell_input_lines { context.shell.options_mut().print_shell_input_lines = value; saw_option = true; } - if let Some(value) = self.print_commands_and_arguments.to_bool() { - context.shell.options_mut().print_commands_and_arguments = value; - saw_option = true; - } - - if let Some(value) = self.perform_brace_expansion.to_bool() { + if let Some(value) = self.perform_brace_expansion { context.shell.options_mut().perform_brace_expansion = value; saw_option = true; } - if let Some(value) = self - .disallow_overwriting_regular_files_via_output_redirection - .to_bool() - { + if let Some(value) = self.disallow_overwriting_regular_files_via_output_redirection { context .shell .options_mut() @@ -289,12 +322,12 @@ impl builtins::Command for SetCommand { saw_option = true; } - if let Some(value) = self.shell_functions_inherit_err_trap.to_bool() { + if let Some(value) = self.shell_functions_inherit_err_trap { context.shell.options_mut().shell_functions_inherit_err_trap = value; saw_option = true; } - if let Some(value) = self.enable_bang_style_history_substitution.to_bool() { + if let Some(value) = self.enable_bang_style_history_substitution { context .shell .options_mut() @@ -302,7 +335,7 @@ impl builtins::Command for SetCommand { saw_option = true; } - if let Some(value) = self.do_not_resolve_symlinks_when_changing_dir.to_bool() { + if let Some(value) = self.do_not_resolve_symlinks_when_changing_dir { context .shell .options_mut() @@ -310,10 +343,7 @@ impl builtins::Command for SetCommand { saw_option = true; } - if let Some(value) = self - .shell_functions_inherit_debug_and_return_traps - .to_bool() - { + if let Some(value) = self.shell_functions_inherit_debug_and_return_traps { context .shell .options_mut() @@ -374,29 +404,41 @@ impl builtins::Command for SetCommand { let args = context.shell.current_shell_args_mut(); - let skip = match self.positional_args.first() { + // N.B. A leading `--` in the captured operands acts as an option + // terminator and is not part of the positional parameters. + let positional_args: &[String] = + if self.positional_args.first().map(String::as_str) == Some("--") { + &self.positional_args[1..] + } else { + &self.positional_args + }; + + let skip = match positional_args.first() { Some(x) if x == "-" => { - if self.positional_args.len() > 1 { + if positional_args.len() > 1 { args.clear(); } 1 } - Some(x) if x == "--" => { - args.clear(); - 1 - } Some(_) => { args.clear(); 0 } - None => 0, + None => { + if self.double_dash_seen { + args.clear(); + } + 0 + } }; - for arg in self.positional_args.iter().skip(skip) { + for arg in positional_args.iter().skip(skip) { args.push(arg.to_owned()); } - saw_option = saw_option || !self.positional_args.is_empty(); + // N.B. A bare `--` counts as an operation on the positional parameters + // rather than as a request to display them. + saw_option = saw_option || !positional_args.is_empty() || self.double_dash_seen; // If we *still* haven't seen any options, then we need to display all variables and // functions. @@ -408,6 +450,56 @@ impl builtins::Command for SetCommand { } } +fn render_parse_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + +/// Splits the value-taking `-o` out of a short-option group so that its +/// attached value parses (e.g., `-ov` becomes `-o=v`, `-eo` becomes `-e -o`). +/// bpaf otherwise cannot recognize an attached value on `o` because it is +/// also registered as a plain flag. +fn expand_dash_o_group(arg: &str) -> Vec { + let Some(group) = arg + .strip_prefix('-') + .filter(|group| !group.is_empty() && !group.starts_with('-') && !group.contains('=')) + else { + return vec![arg.to_owned()]; + }; + + // Split the group at the `-o` option character, if present; any trailing + // characters form an attached option value. + match group.split_once('o') { + None => vec![arg.to_owned()], + Some((head, tail)) => { + let mut expanded = Vec::with_capacity(3); + if !head.is_empty() { + expanded.push(format!("-{head}")); + } + + if tail.is_empty() { + expanded.push(String::from("-o")); + } else { + expanded.push(format!("-o={tail}")); + } + + expanded + } + } +} + fn display_all( context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, ) -> Result<(), brush_core::Error> { diff --git a/brush-builtins/src/shift.rs b/brush-builtins/src/shift.rs index 77dd08749..eee2ecb09 100644 --- a/brush-builtins/src/shift.rs +++ b/brush-builtins/src/shift.rs @@ -1,17 +1,30 @@ -use clap::Parser; +use bpaf::Parser; use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; /// Shift positional arguments. -#[derive(Parser)] pub(crate) struct ShiftCommand { - /// Number of positions to shift the arguments by (defaults to 1). n: Option, } impl builtins::Command for ShiftCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let n = bpaf::positional::("N") + .help("Number of positions to shift the arguments by (defaults to 1).") + .optional(); + bpaf::construct!(ShiftCommand { n }) + } + + fn about() -> &'static str { + "Shift positional arguments." + } + + fn synopsis() -> &'static str { + "[N]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/shopt.rs b/brush-builtins/src/shopt.rs index 8069e3c6e..2c6a3fe19 100644 --- a/brush-builtins/src/shopt.rs +++ b/brush-builtins/src/shopt.rs @@ -1,39 +1,54 @@ -use clap::Parser; +use bpaf::Parser; use itertools::Itertools; use std::io::Write; use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; /// Manage shopt-style options. -#[derive(Parser)] pub(crate) struct ShoptCommand { - /// Manage set -o options. - #[arg(short = 'o')] set_o_names_only: bool, - - /// Print options' current values. - #[arg(short = 'p')] print: bool, - - /// Suppress typical output. - #[arg(short = 'q')] quiet: bool, - - /// Set the specified options. - #[arg(short = 's')] set: bool, - - /// Unset the specified options. - #[arg(short = 'u')] unset: bool, - - /// Names of options to operate on. options: Vec, } impl builtins::Command for ShoptCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let set_o_names_only = bpaf::short('o').help("Manage set -o options.").switch(); + let print = bpaf::short('p') + .help("Print options' current values.") + .switch(); + let quiet = bpaf::short('q').help("Suppress typical output.").switch(); + let set = bpaf::short('s').help("Set the specified options.").switch(); + let unset = bpaf::short('u') + .help("Unset the specified options.") + .switch(); + let options = bpaf::positional::("OPTIONS") + .help("Names of options to operate on.") + .many(); + + bpaf::construct!(ShoptCommand { + set_o_names_only, + print, + quiet, + set, + unset, + options, + }) + } + + fn about() -> &'static str { + "Manage shopt-style options." + } + + fn synopsis() -> &'static str { + "[-opqsu] [OPTIONS]..." + } + #[allow(clippy::too_many_lines)] async fn execute( &self, diff --git a/brush-builtins/src/suspend.rs b/brush-builtins/src/suspend.rs index 513408599..283f98c18 100644 --- a/brush-builtins/src/suspend.rs +++ b/brush-builtins/src/suspend.rs @@ -1,19 +1,31 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; /// Suspend the shell. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct SuspendCommand { /// Force suspend login shells. - #[arg(short = 'f')] + #[bpaf(short('f'))] force: bool, } impl builtins::Command for SuspendCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + suspend_command() + } + + fn about() -> &'static str { + "Suspend the shell." + } + + fn synopsis() -> &'static str { + "[-f]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/test.rs b/brush-builtins/src/test.rs index e9846b24f..d791b619a 100644 --- a/brush-builtins/src/test.rs +++ b/brush-builtins/src/test.rs @@ -1,4 +1,3 @@ -use clap::Parser; use std::io::Write; use brush_core::{ @@ -6,28 +5,43 @@ use brush_core::{ }; /// Evaluate test expression. -#[derive(Parser)] -#[clap(disable_help_flag = true, disable_version_flag = true)] pub(crate) struct TestCommand { - #[clap(allow_hyphen_values = true)] + /// The arguments, interpreted as a test expression. args: Vec, } impl builtins::Command for TestCommand { type Error = brush_core::Error; - /// Override the default [`builtins::Command::new`] function to handle clap's limitation related - /// to `--`. See [`builtins::parse_known`] for more information - /// TODO(test): we can safely remove this after the issue is resolved - fn new(args: I) -> Result + fn parser() -> impl bpaf::Parser { + // N.B. Arguments are captured verbatim in [`Self::new`] because test + // expressions are interpreted entirely by `execute`; the parser exists + // only for help rendering. + let args = bpaf::pure(Vec::new()); + + bpaf::construct!(TestCommand { args }) + } + + fn new(args: I) -> Result where I: IntoIterator, { - let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?; - if let Some(args) = rest_args { - this.args.extend(args); + let mut args: Vec = args.into_iter().collect(); + + // N.B. The first argument is the command name itself. + if !args.is_empty() { + args.remove(0); } - Ok(this) + + Ok(Self { args }) + } + + fn about() -> &'static str { + "Evaluate test expression." + } + + fn synopsis() -> &'static str { + "[EXPRESSION]" } async fn execute( @@ -48,6 +62,17 @@ impl builtins::Command for TestCommand { args = &args[0..args.len() - 1]; } + // N.B. A leading `--` operand ends option processing and is removed, + // except when it is the only operand, in which case it is treated as a + // non-empty string argument; both behaviors match bash. + if args.first().map(String::as_str) == Some("--") { + args = if args.len() == 1 { + return Ok(ExecutionResult::success()); + } else { + &args[1..] + }; + } + if execute_test(context.shell, &context.params, args)? { Ok(ExecutionResult::success()) } else { @@ -65,3 +90,17 @@ fn execute_test( brush_parser::test_command::parse(args).map_err(ErrorKind::TestCommandParseError)?; tests::eval_expr(&test_command, shell, params) } + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod double_dash_tests { + use super::*; + use brush_core::builtins::Command as _; + + #[test] + fn captures_lone_double_dash() -> anyhow::Result<()> { + let cmd = TestCommand::new(["test", "--"].iter().map(|s| s.to_string()))?; + assert_eq!(cmd.args, ["--"]); + Ok(()) + } +} diff --git a/brush-builtins/src/times.rs b/brush-builtins/src/times.rs index f6e39648e..1ce340133 100644 --- a/brush-builtins/src/times.rs +++ b/brush-builtins/src/times.rs @@ -1,15 +1,26 @@ -use clap::Parser; use std::io::Write; use brush_core::{ExecutionResult, builtins, timing}; /// Report on usage time. -#[derive(Parser)] +#[derive(Clone)] pub(crate) struct TimesCommand {} impl builtins::Command for TimesCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + bpaf::construct!(TimesCommand {}) + } + + fn about() -> &'static str { + "Report on usage time." + } + + fn synopsis() -> &'static str { + "" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/trap.rs b/brush-builtins/src/trap.rs index 1b9acf0e0..23be1816f 100644 --- a/brush-builtins/src/trap.rs +++ b/brush-builtins/src/trap.rs @@ -1,26 +1,40 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::traps::TrapSignal; use brush_core::{ExecutionResult, builtins}; /// Manage signal traps. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct TrapCommand { /// List all signal names. - #[arg(short = 'l')] + #[bpaf(short('l'))] list_signals: bool, /// Print registered trap commands. - #[arg(short = 'p')] + #[bpaf(short('p'))] print_trap_commands: bool, + /// Handler command and signals to operate on. + #[bpaf(positional("ARGS"))] args: Vec, } impl builtins::Command for TrapCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + trap_command() + } + + fn about() -> &'static str { + "Manage signal traps." + } + + fn synopsis() -> &'static str { + "[-lp] [ARGS]..." + } + async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/type_.rs b/brush-builtins/src/type_.rs index bcf9840a2..fd421e58c 100644 --- a/brush-builtins/src/type_.rs +++ b/brush-builtins/src/type_.rs @@ -1,36 +1,36 @@ +use bpaf::Bpaf; use std::io::Write; use std::path::{Path, PathBuf}; -use clap::Parser; - use brush_core::sys::{self, fs::PathExt}; use brush_core::{ExecutionResult, Shell, builtins, parser::ast}; /// Inspect the type of a named shell item. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct TypeCommand { /// Display all locations of the specified name, not just the first. - #[arg(short = 'a')] + #[bpaf(short('a'))] all_locations: bool, /// Don't consider functions when resolving the name. - #[arg(short = 'f')] + #[bpaf(short('f'))] suppress_func_lookup: bool, /// Force searching by file path, even if the name is an alias, built-in /// command, or shell function. - #[arg(short = 'P')] + #[bpaf(short('P'))] force_path_search: bool, /// Show file path only. - #[arg(short = 'p')] + #[bpaf(short('p'))] show_path_only: bool, /// Only display the type of the specified name. - #[arg(short = 't')] + #[bpaf(short('t'))] type_only: bool, /// Names to search for. + #[bpaf(positional("NAMES"))] names: Vec, } @@ -45,6 +45,18 @@ enum ResolvedType<'a> { impl builtins::Command for TypeCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + type_command() + } + + fn about() -> &'static str { + "Inspect the type of a named shell item." + } + + fn synopsis() -> &'static str { + "[-aPptf] [NAMES]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/ulimit.rs b/brush-builtins/src/ulimit.rs index 7dd5f4ff2..74b2a8a71 100644 --- a/brush-builtins/src/ulimit.rs +++ b/brush-builtins/src/ulimit.rs @@ -1,8 +1,6 @@ -use clap::{ - Parser, - builder::{IntoResettable, StyledStr}, -}; +use bpaf::Parser; use std::{ + ffi::OsStr, io::{self, ErrorKind, Write}, str::FromStr, }; @@ -96,7 +94,6 @@ impl Resource { #[derive(Clone, Copy)] struct ResourceDescription { resource: Resource, - help: &'static str, description: &'static str, short: char, unit: Unit, @@ -105,147 +102,126 @@ struct ResourceDescription { impl ResourceDescription { const SBSIZE: Self = Self { resource: Resource::Phy(rlimit::Resource::SBSIZE), - help: "the socket buffer size", description: "socket buffer size", short: 'b', unit: Unit::Bytes, }; const CORE: Self = Self { resource: Resource::Phy(rlimit::Resource::CORE), - help: "the maximum size of core files created", description: "core file size", short: 'c', unit: Unit::Block, }; const DATA: Self = Self { resource: Resource::Phy(rlimit::Resource::DATA), - help: "the maximum size of a process's data segment", description: "data seg size", short: 'd', unit: Unit::KBytes, }; const NICE: Self = Self { resource: Resource::Phy(rlimit::Resource::NICE), - help: "the maximum scheduling priority (`nice`)", description: "scheduling priority", short: 'e', unit: Unit::Number, }; const FSIZE: Self = Self { resource: Resource::Phy(rlimit::Resource::FSIZE), - help: "the maximum size of files written by the shell and its children", description: "file size", short: 'f', unit: Unit::Block, }; const SIGPENDING: Self = Self { resource: Resource::Phy(rlimit::Resource::SIGPENDING), - help: "the maximum number of pending signals", description: "pending signals", short: 'i', unit: Unit::Number, }; const MEMLOCK: Self = Self { resource: Resource::Phy(rlimit::Resource::MEMLOCK), - help: "the maximum size a process may lock into memory", description: "max locked memory", short: 'l', unit: Unit::KBytes, }; const KQUEUES: Self = Self { resource: Resource::Phy(rlimit::Resource::KQUEUES), - help: "the maximum number of kqueues allocated for this process", description: "max kqueues", short: 'k', unit: Unit::Number, }; const RSS: Self = Self { resource: Resource::Phy(rlimit::Resource::RSS), - help: "the maximum resident set size", description: "max memory size", short: 'm', unit: Unit::KBytes, }; const LOCKS: Self = Self { resource: Resource::Phy(rlimit::Resource::LOCKS), - help: "the maximum number of file locks", description: "file locks", short: 'x', unit: Unit::Number, }; const NOFILE: Self = Self { resource: Resource::Phy(rlimit::Resource::NOFILE), - help: "the maximum number of open file descriptors", description: "open files", short: 'n', unit: Unit::Number, }; const MSGQUEUE: Self = Self { resource: Resource::Phy(rlimit::Resource::MSGQUEUE), - help: "the maximum number of bytes in POSIX message queues", description: "POSIX message queues", short: 'q', unit: Unit::Bytes, }; const PIPE: Self = Self { resource: Resource::Virt(Virtual::Pipe), - help: "the pipe buffer size", description: "pipe size", short: 'p', unit: Unit::HalfKBytes, }; const RTPRIO: Self = Self { resource: Resource::Phy(rlimit::Resource::RTPRIO), - help: "the maximum real-time scheduling priority", description: "real-time priority", short: 'r', unit: Unit::Number, }; const RTTIME: Self = Self { resource: Resource::Phy(rlimit::Resource::RTTIME), - help: "the maximum real-time scheduling priority", description: "real-time non-blocking time", short: 'R', unit: Unit::Micros, }; const STACK: Self = Self { resource: Resource::Phy(rlimit::Resource::STACK), - help: "the maximum stack size", description: "stack size", short: 's', unit: Unit::KBytes, }; const CPU: Self = Self { resource: Resource::Phy(rlimit::Resource::CPU), - help: "the maximum amount of cpu time in seconds", description: "cpu time", short: 't', unit: Unit::Seconds, }; const NPROC: Self = Self { resource: Resource::Phy(rlimit::Resource::NPROC), - help: "the maximum number of user processes", description: "max user processes", short: 'u', unit: Unit::Number, }; const VMEM: Self = Self { resource: Resource::Virt(Virtual::VMem), - help: "the size of virtual memory", description: "virtual memory", short: 'v', unit: Unit::KBytes, }; const THREADS: Self = Self { resource: Resource::Phy(rlimit::Resource::THREADS), - help: "the maximum number of threads", description: "number of threads", short: 'T', unit: Unit::Number, }; const NPTS: Self = Self { resource: Resource::Phy(rlimit::Resource::NPTS), - help: "the maximum number of pseudoterminals", description: "number of pseudoterminals", short: 'P', unit: Unit::Number, @@ -306,25 +282,6 @@ impl ResourceDescription { resource ) } - - /// Provide the matching help String - fn help(&self) -> String { - format!( - "{} {}", - self.help, - if self.resource.is_supported() { - "(supported)" - } else { - "(unsupported)" - } - ) - } -} - -impl IntoResettable for ResourceDescription { - fn into_resettable(self) -> clap::builder::Resettable { - clap::builder::Resettable::Value(self.help().into()) - } } #[derive(Debug, Clone, Copy)] @@ -350,92 +307,243 @@ impl FromStr for LimitValue { } } +/// Returns a parser for a resource-limit switch that may be specified either +/// with a value (`-c 5`) or without one (`-c`, meaning "report this limit"). +fn limit_switch(short: char, desc: &'static str) -> impl bpaf::Parser> { + let with_value = bpaf::short(short) + .help(desc) + .argument::("LIMIT"); + let without_value = bpaf::short(short) + .req_flag(()) + .map(|(): ()| LimitValue::Unset); + + bpaf::construct!([with_value, without_value]).optional() +} + +const SWITCH_SHORTS: &[char] = &['S', 'H', 'a']; +const VALUE_SHORTS: &[char] = &[ + 'b', 'c', 'd', 'e', 'f', 'i', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'x', 'P', + 'R', 'T', +]; + +/// Splits attached values off of resource-limit option groups (e.g., `-c5` +/// becomes `-c=5` and `-Sc` becomes `-S -c`) so that grouped forms parse; +/// bpaf cannot disambiguate shorts that are registered as both flags and +/// value-taking arguments. +fn expand_limit_option_groups(args: Vec) -> Vec { + let mut expanded = Vec::with_capacity(args.len()); + + for arg in args { + let Some(group) = arg + .strip_prefix('-') + .filter(|group| !group.is_empty() && !group.starts_with('-') && !group.contains('=')) + else { + expanded.push(arg); + continue; + }; + + let Some((head, value_short, tail)) = + split_group_at_value_short(group, &VALUE_SHORTS.iter().collect::()) + else { + expanded.push(arg); + continue; + }; + + if !head.chars().all(|c| SWITCH_SHORTS.contains(&c)) { + expanded.push(arg); + continue; + } + + if !head.is_empty() { + expanded.push(format!("-{head}")); + } + + if tail.is_empty() { + expanded.push(format!("-{value_short}")); + } else { + expanded.push(format!("-{value_short}={tail}")); + } + } + + expanded +} + +/// Splits the given short-option group at its first value-taking option +/// character, returning the leading switch characters, the value-taking +/// character itself, and any attached value. +fn split_group_at_value_short<'a>( + group: &'a str, + value_shorts: &str, +) -> Option<(&'a str, char, &'a str)> { + for (head, rest) in group + .char_indices() + .map(|(ix, _)| group.split_at(ix)) + .skip(1) + { + let mut chars = rest.chars(); + let Some(c) = chars.next() else { + continue; + }; + + if value_shorts.contains(c) { + return Some((head, c, chars.as_str())); + } + } + + None +} + +fn render_parse_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParseError { + match failure { + bpaf::ParseFailure::Stdout(doc, full) => builtins::BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + bpaf::ParseFailure::Completion(s) => builtins::BuiltinArgParseError { + message: s, + help_request: true, + }, + bpaf::ParseFailure::Stderr(doc) => builtins::BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + /// Modify shell resource limits. /// /// Provides control over the resources available to the shell and processes /// it creates, on systems that allow such control. -#[derive(Parser, Debug)] pub(crate) struct ULimitCommand { - /// use the `soft` resource limit - #[arg(short = 'S')] + #[expect(dead_code)] soft: bool, - /// use the `hard` resource limit - #[arg(short = 'H')] hard: bool, - /// all current limits are reported - #[arg(short = 'a')] all: bool, - /// the maximum socket buffer size - #[arg(short = 'b', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SBSIZE)] sbsize: Option, - /// the maximum size of core files created - #[arg(short = 'c', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CORE)] core: Option, - /// the maximum size of a process's data segment - #[arg(short = 'd', default_missing_value = "", num_args(0..=1), help = ResourceDescription::DATA)] data: Option, - /// the maximum scheduling priority (`nice`) - #[arg(short = 'e', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NICE)] nice: Option, - /// the maximum size of files written by the shell and its children - #[arg(short = 'f', default_missing_value = "", num_args(0..=1), help = ResourceDescription::FSIZE)] file_size: Option, - /// the maximum number of pending signals - #[arg(short = 'i', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SIGPENDING)] sigpending: Option, - /// the maximum size a process may lock into memory - #[arg(short = 'l', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MEMLOCK)] memlock: Option, - /// the maximum number of kqueues allocated for this process - #[arg(short = 'k', default_missing_value = "", num_args(0..=1), help = ResourceDescription::KQUEUES)] kqueues: Option, - /// the maximum resident set size - #[arg(short = 'm', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RSS)] rss: Option, - /// the maximum number of open file descriptors - #[arg(short = 'n', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NOFILE)] file_open: Option, - /// the pipe buffer size - #[arg(short = 'p', default_missing_value = "", num_args(0..=1), help = ResourceDescription::PIPE)] pipe: Option, - /// the maximum number of bytes in POSIX message queues - #[arg(short = 'q', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MSGQUEUE)] msgqueue: Option, - /// the maximum real-time scheduling priority - #[arg(short = 'r', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTPRIO)] rtprio: Option, - /// the maximum stack size - #[arg(short = 's', default_missing_value = "", num_args(0..=1), help = ResourceDescription::STACK)] + rttime: Option, stack: Option, - /// the maximum amount of cpu time in seconds - #[arg(short = 't', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CPU)] cpu: Option, - /// the size of virtual memory - #[arg(short = 'u', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPROC)] nproc: Option, - /// the size of virtual memory - #[arg(short = 'v', default_missing_value = "", num_args(0..=1), help = ResourceDescription::VMEM)] vmem: Option, - /// the maximum number of file locks - #[arg(short = 'x', default_missing_value = "", num_args(0..=1), help = ResourceDescription::LOCKS)] file_lock: Option, - /// the maximum number of pseudoterminals - #[arg(short = 'P', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPTS)] npts: Option, - /// real-time non-blocking time - #[arg(short = 'R', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTTIME)] - rttime: Option, - /// the maximum number of threads - #[arg(short = 'T', default_missing_value = "", num_args(0..=1), help = ResourceDescription::THREADS)] threads: Option, - - /// argument for the implicit limit (`-f`) limit: Option, } impl builtins::Command for ULimitCommand { type Error = brush_core::Error; + /// Overrides the default [`builtins::Command::new`] flow to split attached + /// values out of grouped resource options first; see + /// [`expand_limit_option_groups`]. + fn new(args: I) -> Result + where + I: IntoIterator, + { + // N.B. The first argument is the command name itself. + let args: Vec = args.into_iter().skip(1).collect(); + let expanded = expand_limit_option_groups(args); + let os_args: Vec<&OsStr> = expanded.iter().map(OsStr::new).collect(); + + Self::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_parse_failure) + } + + fn parser() -> impl bpaf::Parser { + let soft = bpaf::short('S') + .help("Use the `soft` resource limit.") + .switch(); + let hard = bpaf::short('H') + .help("Use the `hard` resource limit.") + .switch(); + let all = bpaf::short('a') + .help("All current limits are reported.") + .switch(); + + let sbsize = limit_switch('b', "The maximum socket buffer size."); + let core = limit_switch('c', "The maximum size of core files created."); + let data = limit_switch('d', "The maximum size of a process's data segment."); + let nice = limit_switch('e', "The maximum scheduling priority (`nice`)."); + let file_size = limit_switch( + 'f', + "The maximum size of files written by the shell and its children.", + ); + let sigpending = limit_switch('i', "The maximum number of pending signals."); + let memlock = limit_switch('l', "The maximum size a process may lock into memory."); + let kqueues = limit_switch( + 'k', + "The maximum number of kqueues allocated for this process.", + ); + let rss = limit_switch('m', "The maximum resident set size."); + let file_open = limit_switch('n', "The maximum number of open file descriptors."); + let pipe = limit_switch('p', "The pipe buffer size."); + let msgqueue = limit_switch('q', "The maximum number of bytes in POSIX message queues."); + let rtprio = limit_switch('r', "The maximum real-time scheduling priority."); + let rttime = limit_switch('R', "Real-time non-blocking time."); + let stack = limit_switch('s', "The maximum stack size."); + let cpu = limit_switch('t', "The maximum amount of cpu time in seconds."); + let nproc = limit_switch('u', "The maximum number of user processes."); + let vmem = limit_switch('v', "The size of virtual memory."); + let file_lock = limit_switch('x', "The maximum number of file locks."); + let npts = limit_switch('P', "The maximum number of pseudoterminals."); + let threads = limit_switch('T', "The maximum number of threads."); + + let limit = bpaf::positional::("LIMIT") + .help("Argument for the implicit limit (`-f`).") + .optional(); + + bpaf::construct!(ULimitCommand { + soft, + hard, + all, + sbsize, + core, + data, + nice, + file_size, + sigpending, + memlock, + kqueues, + rss, + file_open, + pipe, + msgqueue, + rtprio, + rttime, + stack, + cpu, + nproc, + vmem, + file_lock, + npts, + threads, + limit, + }) + } + + fn about() -> &'static str { + "Modify shell resource limits." + } + + fn synopsis() -> &'static str { + "[-SHa] [LIMIT]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/umask.rs b/brush-builtins/src/umask.rs index 08a7c368a..9c725ee19 100644 --- a/brush-builtins/src/umask.rs +++ b/brush-builtins/src/umask.rs @@ -1,28 +1,42 @@ +use bpaf::Bpaf; + use brush_core::{ErrorKind, ExecutionResult, builtins}; use cfg_if::cfg_if; -use clap::Parser; #[cfg(not(any(target_os = "linux", target_os = "android")))] use nix::sys::stat::Mode; use std::io::Write; /// Manage the process umask. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct UmaskCommand { /// If MODE is omitted, output in a form that may be reused as input. - #[arg(short = 'p')] + #[bpaf(short('p'))] print_roundtrippable: bool, /// Makes the output symbolic; otherwise an octal number is given. - #[arg(short = 'S')] + #[bpaf(short('S'))] symbolic_output: bool, /// Mode mask. + #[bpaf(positional("MODE"))] mode: Option, } impl builtins::Command for UmaskCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + umask_command() + } + + fn about() -> &'static str { + "Manage the process umask." + } + + fn synopsis() -> &'static str { + "[-pS] [MODE]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/unalias.rs b/brush-builtins/src/unalias.rs index c2cd283fa..60768df92 100644 --- a/brush-builtins/src/unalias.rs +++ b/brush-builtins/src/unalias.rs @@ -1,22 +1,35 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionResult, builtins}; /// Unset a shell alias. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct UnaliasCommand { /// Remove all aliases. - #[arg(short = 'a')] + #[bpaf(short('a'))] remove_all: bool, /// Names of aliases to operate on. + #[bpaf(positional("ALIASES"))] aliases: Vec, } impl builtins::Command for UnaliasCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + unalias_command() + } + + fn about() -> &'static str { + "Unset a shell alias." + } + + fn synopsis() -> &'static str { + "[-a] [ALIASES]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/unimp.rs b/brush-builtins/src/unimp.rs index 2299391bc..a05c2fe51 100644 --- a/brush-builtins/src/unimp.rs +++ b/brush-builtins/src/unimp.rs @@ -1,17 +1,20 @@ +use bpaf::Parser; use brush_core::{ExecutionExitCode, builtins, trace_categories}; -use clap::Parser; - /// (UNIMPLEMENTED COMMAND) -#[derive(Parser)] pub(crate) struct UnimplementedCommand { - #[clap(allow_hyphen_values = true)] args: Vec, } impl builtins::Command for UnimplementedCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + // Capture all arguments verbatim; no option parsing is performed. + let args = bpaf::any("ARGS", Some).many(); + bpaf::construct!(UnimplementedCommand { args }) + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/unset.rs b/brush-builtins/src/unset.rs index d07759aa7..b09db62d1 100644 --- a/brush-builtins/src/unset.rs +++ b/brush-builtins/src/unset.rs @@ -1,43 +1,55 @@ +use bpaf::Parser; use std::borrow::Cow; -use clap::Parser; - use brush_core::{ExecutionResult, Shell, builtins}; +/// How the names passed to `unset` should be interpreted. +#[derive(Clone, Copy, PartialEq, Eq)] +enum NameInterpretation { + Functions, + Variables, + NameRefs, +} + /// Unset a variable. -#[derive(Parser)] pub(crate) struct UnsetCommand { - #[clap(flatten)] - name_interpretation: UnsetNameInterpretation, - - /// Names of variables to unset. + name_interpretation: Option, names: Vec, } -#[derive(Parser)] -#[clap(group = clap::ArgGroup::new("name-interpretation").multiple(false).required(false))] -pub(crate) struct UnsetNameInterpretation { - /// Treat each name as a shell function. - #[arg(short = 'f', group = "name-interpretation")] - shell_functions: bool, - - /// Treat each name as a shell variable. - #[arg(short = 'v', group = "name-interpretation")] - shell_variables: bool, +impl builtins::Command for UnsetCommand { + type Error = brush_core::Error; - /// Treat each name as a name reference. - #[arg(short = 'n', group = "name-interpretation")] - name_references: bool, -} + fn parser() -> impl bpaf::Parser { + let functions = bpaf::short('f') + .help("Treat each name as a shell function.") + .req_flag(NameInterpretation::Functions); + let variables = bpaf::short('v') + .help("Treat each name as a shell variable.") + .req_flag(NameInterpretation::Variables); + let name_refs = bpaf::short('n') + .help("Treat each name as a name reference.") + .req_flag(NameInterpretation::NameRefs); + + let name_interpretation = bpaf::construct!([functions, variables, name_refs]).optional(); + + let names = bpaf::positional::("NAMES") + .help("Names of variables to unset.") + .many(); + + bpaf::construct!(UnsetCommand { + name_interpretation, + names, + }) + } -impl UnsetNameInterpretation { - pub const fn unspecified(&self) -> bool { - !self.shell_functions && !self.shell_variables && !self.name_references + fn about() -> &'static str { + "Unset values and attributes of variables and functions." } -} -impl builtins::Command for UnsetCommand { - type Error = brush_core::Error; + fn synopsis() -> &'static str { + "[-fvn] [NAMES]..." + } async fn execute( &self, @@ -46,15 +58,15 @@ impl builtins::Command for UnsetCommand { // // TODO(nameref): implement nameref // - if self.name_interpretation.name_references { + if self.name_interpretation == Some(NameInterpretation::NameRefs) { return brush_core::error::unimp("unset: name references are not yet implemented"); } - let unspecified = self.name_interpretation.unspecified(); + let unspecified = self.name_interpretation.is_none(); #[expect(clippy::needless_continue)] for name in &self.names { - if unspecified || self.name_interpretation.shell_variables { + if unspecified || self.name_interpretation == Some(NameInterpretation::Variables) { // Try to parse the name as a parameter. If we can't, don't bail; it may not be a // valid variable name/parameter but could still be a function name. if let Ok(parameter) = @@ -82,7 +94,7 @@ impl builtins::Command for UnsetCommand { } // TODO(unset): Deal with readonly functions - if unspecified || self.name_interpretation.shell_functions { + if unspecified || self.name_interpretation == Some(NameInterpretation::Functions) { if context.shell.undefine_func(name) { continue; } diff --git a/brush-builtins/src/wait.rs b/brush-builtins/src/wait.rs index 05b200975..794030edb 100644 --- a/brush-builtins/src/wait.rs +++ b/brush-builtins/src/wait.rs @@ -1,31 +1,44 @@ -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error}; /// Wait for jobs to terminate. -#[derive(Parser)] +#[derive(Bpaf)] pub(crate) struct WaitCommand { /// Wait for specified job to terminate (instead of change status). - #[arg(short = 'f')] + #[bpaf(short('f'))] wait_for_terminate: bool, /// Wait for a single job to change status; if jobs are specified, waits for /// the first to change status, and otherwise waits for the next change. - #[arg(short = 'n')] + #[bpaf(short('n'))] wait_for_first_or_next: bool, /// Name of variable to receive the job ID of the job whose status is indicated. - #[arg(short = 'p', value_name = "VAR_NAME")] + #[bpaf(short('p'), argument("VAR_NAME"))] variable_to_receive_id: Option, /// Process IDs or job specs to wait for. + #[bpaf(positional("IDS"))] ids: Vec, } impl builtins::Command for WaitCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + wait_command() + } + + fn about() -> &'static str { + "Wait for jobs to terminate." + } + + fn synopsis() -> &'static str { + "[-fn] [-p VAR_NAME] [IDS]..." + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-core/Cargo.toml b/brush-core/Cargo.toml index 394b57595..1946557a4 100644 --- a/brush-core/Cargo.toml +++ b/brush-core/Cargo.toml @@ -29,7 +29,7 @@ bon = "3.9.1" cached = "2.0.2" cfg-if = "1.0.4" chrono = "0.4.44" -clap = { version = "4.6.0", features = ["derive", "wrap_help"] } +bpaf = { version = "0.9.27", features = ["derive", "bright-color"] } color-print = "0.3.7" fancy-regex = "0.19.0" futures = "0.3.32" diff --git a/brush-core/examples/custom-builtin.rs b/brush-core/examples/custom-builtin.rs index c32de4041..490b5cab8 100644 --- a/brush-core/examples/custom-builtin.rs +++ b/brush-core/examples/custom-builtin.rs @@ -3,7 +3,7 @@ //! This example demonstrates best practices for: //! - Creating a custom builtin command using the `Command` trait //! - Defining custom error types with `thiserror` -//! - Parsing command-line arguments with `clap` +//! - Parsing command-line arguments with `bpaf` //! - Implementing proper error handling and exit code conversion //! - Using the execution context to interact with shell state and I/O streams //! @@ -13,7 +13,7 @@ //! ``` use anyhow::Result; -use clap::Parser; +use bpaf::Bpaf; use std::io::Write; use brush_core::{ExecutionResult, builtins}; @@ -61,23 +61,25 @@ impl From<&GreetError> for brush_core::ExecutionExitCode { // // Step 2 (recommended): Define your builtin command arguments // ============================================== -// We recommend using the `clap` crate and the derive-able `clap::Parser` to define -// command-line arguments and options. This will simplify the work you need to do -// to provide helpful usage information and auto-generated argument validation. +// We recommend using the `bpaf` crate and its derive-able `Bpaf` (or +// combinatoric) APIs to define command-line arguments and options. This will +// simplify the work you need to do to provide helpful usage information and +// argument validation. // /// Greet the user with a friendly message. -#[derive(Parser)] +#[derive(Clone, Bpaf, Debug)] struct GreetCommand { /// Number of times to repeat the greeting. - #[arg(short = 'n', long = "repeat", default_value_t = 1)] + #[bpaf(short('n'), long("repeat"), fallback(1))] repeat_count: usize, } // // Step 3: Implement the Command trait // ============================================== -// The `Command` trait requires implementing the `execute` method. +// The `Command` trait requires implementing the `parser` and `execute` +// methods. // impl builtins::Command for GreetCommand { @@ -85,6 +87,18 @@ impl builtins::Command for GreetCommand { // the default-provided `brush_core::Error` type. type Error = GreetError; + fn parser() -> impl builtins::Parser { + greet_command() + } + + fn about() -> &'static str { + "Greet the user with a friendly message." + } + + fn synopsis() -> &'static str { + "[-n REPEAT]" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-core/src/builtins.rs b/brush-core/src/builtins.rs index 66692b9c3..ef518a966 100644 --- a/brush-core/src/builtins.rs +++ b/brush-core/src/builtins.rs @@ -1,7 +1,9 @@ //! Facilities for implementing and managing builtins -use clap::builder::styling; +pub use bpaf::Parser; +use bpaf::{Args, ParseFailure}; pub use futures::future::BoxFuture; +use std::ffi::OsStr; use std::io::Write; use crate::{BuiltinError, CommandArg, commands, error, extensions, results}; @@ -29,46 +31,259 @@ pub type CommandExecuteFunc = pub type CommandContentFunc = fn(&str, ContentType, &ContentOptions) -> Result; +/// An error that occurred while parsing a built-in command's arguments. +#[derive(Debug, Clone)] +pub struct BuiltinArgParseError { + /// The rendered message associated with the parse failure. + pub message: String, + + /// Whether or not this "error" is actually a request to display help + /// (or version) information, in which case the message should be + /// displayed and the builtin should exit successfully. + pub help_request: bool, +} + +impl std::fmt::Display for BuiltinArgParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for BuiltinArgParseError {} + +fn render_parse_failure(failure: ParseFailure) -> BuiltinArgParseError { + match failure { + // Help/version requests are rendered to stdout with a success exit code. + ParseFailure::Stdout(doc, full) => BuiltinArgParseError { + message: doc.monochrome(full), + help_request: true, + }, + ParseFailure::Completion(s) => BuiltinArgParseError { + message: s, + help_request: true, + }, + // Everything else is a usage error. + ParseFailure::Stderr(doc) => BuiltinArgParseError { + message: doc.monochrome(true), + help_request: false, + }, + } +} + +/// Splits an argument list into the leading section of options (to be parsed) +/// and the trailing section of operands (captured verbatim), following +/// shell-style option termination rules: +/// +/// * Parsing stops at the first `--`, which acts purely as an option +/// terminator (i.e., it is dropped from the output). +/// * Parsing stops at the first operand (a token that does not look like an +/// option), which starts the trailing section. +/// * Options listed in `value_takers` consume a following value, either +/// attached to the same token or as the next token (even if it looks like +/// an option). Short options are given as the characters (e.g., `"dnOsu"`), +/// while long options are given by full name (e.g., `"--config"`). +/// +/// # Arguments +/// +/// * `args` - The arguments to split. +/// * `value_takers` - The options that take a value. +#[must_use] +pub fn split_option_section( + args: &[String], + value_shorts: &str, + value_longs: &[&str], +) -> (Vec, Vec) { + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + + // N.B. A bare `--` acts purely as an option terminator: it is removed + // from the option section and placed at the front of the trailing + // section, where commands may interpret it as they see fit. + if arg == "--" { + return (args[..i].to_vec(), args[i..].to_vec()); + } + + if is_long_option(arg) { + if value_longs.contains(&arg) { + // A long option known to take a value in a separate token. + i += 2; + } else { + // Any other long-style option (possibly with an attached + // value). + i += 1; + } + } else if is_short_or_plus_option(arg, '-', value_shorts) { + // A group of short options, possibly with an attached value. + i += short_group_token_count(arg.strip_prefix('-').unwrap_or(""), value_shorts); + } else if is_short_or_plus_option(arg, '+', value_shorts) { + // A group of plus-style options (e.g., `set +vx`), possibly with + // an attached value. + i += short_group_token_count(arg.strip_prefix('+').unwrap_or(""), value_shorts); + } else { + // An operand; everything from here on is captured verbatim. + return (args[..i].to_vec(), args[i..].to_vec()); + } + } + + (args.to_vec(), Vec::new()) +} + +/// Returns whether the given token looks like a long option, i.e., `--` followed +/// by a name of word characters, optionally with an attached `=value`. +fn is_long_option(arg: &str) -> bool { + let Some(long) = arg.strip_prefix("--").filter(|l| !l.is_empty()) else { + return false; + }; + + let name = long.split('=').next().unwrap_or(""); + let mut chars = name.chars(); + let first_ok = chars + .next() + .is_some_and(|c| c.is_alphanumeric() || c == '_'); + + first_ok + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') +} + +/// Returns whether the given token looks like a group of short (or +/// plus-style) options, i.e., a leading `-` or `+` followed by one or more +/// alphabetic characters, optionally ending in an attached value beginning at +/// the first value-taking option character. Tokens like `----------------`, +/// `-9223372036854775808`, or `-e hello world` do not qualify and are treated +/// as operands. +fn is_short_or_plus_option(arg: &str, lead: char, value_shorts: &str) -> bool { + let Some(group) = arg.strip_prefix(lead) else { + return false; + }; + + let mut saw_value_taker = false; + for c in group.chars() { + if c.is_whitespace() { + return false; + } + + if saw_value_taker { + // The remainder of the token is an attached value. + continue; + } + + if value_shorts.contains(c) { + saw_value_taker = true; + } else if !c.is_alphabetic() { + return false; + } + } + + !group.is_empty() +} + +/// Returns the number of tokens occupied by a short (or plus-style) option +/// group: one if any value-taking option in the group has its value attached +/// or takes no value, and two if the last option in the group takes a value +/// in a separate token. +fn short_group_token_count(group: &str, value_shorts: &str) -> usize { + let char_count = group.chars().count(); + for (j, c) in group.chars().enumerate() { + if value_shorts.contains(c) { + return if j == char_count - 1 { 2 } else { 1 }; + } + } + + 1 +} + /// Trait implemented by built-in shell commands. -pub trait Command: clap::Parser { +pub trait Command: Sized { /// The error type returned by the command. type Error: BuiltinError + 'static; + /// Returns the parser used to interpret the command's arguments. + /// + /// Implementations are expected to use `bpaf`'s combinatoric or derive + /// APIs. The returned parser is wrapped in an [`bpaf::OptionParser`] by + /// the default implementations of [`Command::new`] and + /// [`Command::get_content`], which also add standard `--help` handling. + fn parser() -> impl Parser + 'static; + + /// Returns a short, one-line description of the command, used by the + /// `help` builtin. + fn about() -> &'static str { + "" + } + + /// Returns a short synopsis of the command's arguments (excluding the + /// command name), used by the `help` builtin; e.g., `"[-abc] [NAME]..."`. + fn synopsis() -> &'static str { + "" + } + + /// Returns whether or not the command takes options with a leading '+' character. + fn takes_plus_options() -> bool { + false + } + + /// Returns whether or not the command captures all remaining arguments, + /// verbatim, after its options. + fn takes_trailing_args() -> bool { + false + } + + /// Returns the characters of short options that take a value; used when + /// deciding where the option section ends for commands that take trailing + /// arguments; e.g., `"dnOsu"`. + fn value_taking_short_options() -> &'static str { + "" + } + /// Instantiates the built-in command with the given arguments. /// /// # Arguments /// /// * `args` - The arguments to the command. - fn new(args: I) -> Result + fn new(args: I) -> Result where I: IntoIterator, { - if !Self::takes_plus_options() { - Self::try_parse_from(args) + let mut args: Vec = args.into_iter().collect(); + + // N.B. The first argument is the command name itself and is not part + // of the arguments to parse. + if !args.is_empty() { + args.remove(0); + } + + // Expand groups of plus-style options (e.g., `set +vx`) into + // individually recognizable tokens (e.g., `+v +x`), mirroring how the + // shell tokenizes option groups. + let args = if Self::takes_plus_options() { + expand_plus_option_groups(args) } else { - let args = args.into_iter(); + args + }; - let (lower, _) = args.size_hint(); + if Self::takes_trailing_args() { + let (options, trailing) = + split_option_section(&args, Self::value_taking_short_options(), &[]); - // N.B. clap doesn't support named options like '+x'. To work around this, we - // establish a pattern of renaming them. - let mut updated_args = Vec::with_capacity(lower); - for arg in args { - if let Some(plus_options) = arg.strip_prefix("+") { - updated_args.extend(plus_options.chars().map(|c| format!("--+{c}"))); - } else { - updated_args.push(arg); - } - } + let mut command = run_parser::(&options)?; + command.set_trailing_args(trailing); - Self::try_parse_from(updated_args) + Ok(command) + } else { + run_parser::(&args) } } - /// Returns whether or not the command takes options with a leading '+' or '-' character. - fn takes_plus_options() -> bool { - false - } + /// Stores trailing (verbatim) arguments captured by [`Command::new`] for + /// commands where [`Command::takes_trailing_args`] returns `true`. + /// + /// # Arguments + /// + /// * `args` - The trailing arguments. + fn set_trailing_args(&mut self, _args: Vec) {} /// Executes the built-in command in the provided context. /// @@ -94,27 +309,74 @@ pub trait Command: clap::Parser { content_type: ContentType, options: &ContentOptions, ) -> Result { - let mut clap_command = Self::command() - .styles(brush_help_styles()) - .next_line_help(false); - clap_command.set_bin_name(name); + let _ = options; let s = match content_type { - ContentType::DetailedHelp => { - let rendered = clap_command.render_help(); - if options.colorized { - rendered.ansi().to_string() + ContentType::DetailedHelp => detailed_help::(name)?, + ContentType::ShortUsage => format!("{name}: {name} {}\n", Self::synopsis()), + ContentType::ShortDescription => format!("{name} - {}\n", Self::about()), + ContentType::ManPage => get_builtin_man_page(name)?, + }; + + Ok(s) + } +} + +/// Renders the given command's detailed help text. +fn detailed_help(name: &str) -> Result { + // N.B. We trigger bpaf's --help handling to render the help content since + // rendered help text is not otherwise exposed via the public API. + let help_args = [OsStr::new("--help")]; + let help_request = Args::from(&help_args[..]).set_name(name); + match T::parser().to_options().run_inner(help_request) { + Err(failure) => Ok(render_parse_failure(failure).message), + Ok(_) => Err(error::ErrorKind::Unimplemented("unexpectedly parsed help request").into()), + } +} + +/// Expands groups of plus-style options (e.g., `+vx`) into individually +/// recognizable tokens (e.g., `+v` and `+x`), mirroring how the shell +/// tokenizes option groups. Tokens that do not start with a single `+` are +/// passed through unchanged. +fn expand_plus_option_groups(args: Vec) -> Vec { + args.into_iter() + .flat_map(|arg| { + if let Some(plus_options) = arg.strip_prefix('+').filter(|g| !g.is_empty()) { + if plus_options.starts_with('+') || plus_options.contains('=') { + // Not an option group (e.g., `++x` or `+foo=bar`); + // pass it through unchanged. + vec![arg] } else { - rendered.to_string() + plus_options + .chars() + .map(|c| format!("+{c}")) + .collect::>() } + } else { + vec![arg] } - ContentType::ShortUsage => get_builtin_short_usage(name, &clap_command), - ContentType::ShortDescription => get_builtin_short_description(name, &clap_command), - ContentType::ManPage => get_builtin_man_page(name, &clap_command)?, - }; + }) + .collect() +} - Ok(s) +/// Parses only an option section (already stripped of the command name) for +/// the given command; used by declaration-style builtins whose operands are +/// handled separately from their options. +fn parse_options_only(mut options: Vec) -> Result { + if T::takes_plus_options() { + options = expand_plus_option_groups(options); } + + run_parser::(&options) +} + +/// Runs the given command's parser against the provided arguments. +fn run_parser(args: &[String]) -> Result { + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + T::parser() + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_parse_failure) } /// Trait implemented by built-in shell commands that take specially handled declarations @@ -177,185 +439,10 @@ impl Registration { } } -fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result { +fn get_builtin_man_page(_name: &str) -> Result { error::unimp("man page rendering is not yet implemented") } -fn get_builtin_short_description(name: &str, command: &clap::Command) -> String { - let about = command - .get_about() - .map_or_else(String::new, |s| s.to_string()); - - std::format!("{name} - {about}\n") -} - -fn get_builtin_short_usage(name: &str, command: &clap::Command) -> String { - let mut usage = String::new(); - - let mut needs_space = false; - - let mut optional_short_opts = vec![]; - let mut required_short_opts = vec![]; - for opt in command.get_opts() { - if opt.is_hide_set() { - continue; - } - - if let Some(c) = opt.get_short() { - if !opt.is_required_set() { - optional_short_opts.push(c); - } else { - required_short_opts.push(c); - } - } - } - - if !optional_short_opts.is_empty() { - if needs_space { - usage.push(' '); - } - - usage.push('['); - usage.push('-'); - for c in optional_short_opts { - usage.push(c); - } - - usage.push(']'); - needs_space = true; - } - - if !required_short_opts.is_empty() { - if needs_space { - usage.push(' '); - } - - usage.push('-'); - for c in required_short_opts { - usage.push(c); - } - - needs_space = true; - } - - for pos in command.get_positionals() { - if pos.is_hide_set() { - continue; - } - - if !pos.is_required_set() { - if needs_space { - usage.push(' '); - } - - usage.push('['); - needs_space = false; - } - - if let Some(names) = pos.get_value_names() { - for name in names { - if needs_space { - usage.push(' '); - } - - usage.push_str(name); - needs_space = true; - } - } - - if !pos.is_required_set() { - usage.push(']'); - needs_space = true; - } - } - - std::format!("{name}: {name} {usage}\n") -} - -fn brush_help_styles() -> clap::builder::Styles { - styling::Styles::styled() - .header( - styling::AnsiColor::Yellow.on_default() - | styling::Effects::BOLD - | styling::Effects::UNDERLINE, - ) - .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD) - .literal(styling::AnsiColor::Magenta.on_default() | styling::Effects::BOLD) - .placeholder(styling::AnsiColor::Cyan.on_default()) -} - -/// This function and the [`try_parse_known`] exists to deal with -/// the Clap's limitation of treating `--` like a regular value -/// `https://github.com/clap-rs/clap/issues/5055` -/// -/// # Arguments -/// -/// * `args` - An Iterator from [`std::env::args`] -/// -/// # Returns -/// -/// * a parsed struct T from [`clap::Parser::parse_from`] -/// * the remain iterator `args` with `--` and the rest arguments if they present otherwise None -/// -/// # Examples -/// ``` -/// use clap::{builder::styling, Parser}; -/// #[derive(Parser)] -/// struct CommandLineArgs { -/// #[clap(allow_hyphen_values = true, num_args=1..)] -/// script_args: Vec, -/// } -/// -/// let (mut parsed_args, raw_args) = -/// brush_core::builtins::parse_known::(std::env::args()); -/// if raw_args.is_some() { -/// parsed_args.script_args = raw_args.unwrap().collect(); -/// } -/// ``` -pub fn parse_known( - args: impl IntoIterator, -) -> (T, Option>) -where - S: Into + Clone + PartialEq<&'static str>, -{ - let mut args = args.into_iter(); - // the best way to save `--` is to get it out with a side effect while `clap` iterates over the - // args this way we can be 100% sure that we have '--' and the remaining args - // and we will iterate only once - let mut hyphen = None; - let args_before_hyphen = args.by_ref().take_while(|a| { - let is_hyphen = *a == "--"; - if is_hyphen { - hyphen = Some(a.clone()); - } - !is_hyphen - }); - let parsed_args = T::parse_from(args_before_hyphen); - let raw_args = hyphen.map(|hyphen| std::iter::once(hyphen).chain(args)); - (parsed_args, raw_args) -} - -/// Similar to [`parse_known`] but with [`clap::Parser::try_parse_from`] -/// This function is used to parse arguments in builtins such as -/// `crate::echo::EchoCommand` -pub fn try_parse_known( - args: impl IntoIterator, -) -> Result<(T, Option>), clap::Error> { - let mut args = args.into_iter(); - let mut hyphen = None; - let args_before_hyphen = args.by_ref().take_while(|a| { - let is_hyphen = a == "--"; - if is_hyphen { - hyphen = Some(a.clone()); - } - !is_hyphen - }); - let parsed_args = T::try_parse_from(args_before_hyphen)?; - - let raw_args = hyphen.map(|hyphen| std::iter::once(hyphen).chain(args)); - Ok((parsed_args, raw_args)) -} - /// A simple command that can be registered as a built-in. pub trait SimpleCommand { /// Returns the content of the built-in command. @@ -414,10 +501,10 @@ pub fn decl_builtin command, - Err(e) => { - let _ = writeln!(context.stderr(), "{e}"); - return Ok(results::ExecutionExitCode::InvalidUsage.into()); - } + Err(e) => return Ok(report_arg_parse_error(&context, &e)), }; call_builtin(command, context).await } +/// Reports a built-in argument parse error to the appropriate streams and +/// returns the corresponding execution result. +/// +/// Help requests are reported to standard output and yield a successful exit +/// code; usage errors are reported to standard error and yield an invalid +/// usage exit code. +fn report_arg_parse_error( + context: &commands::ExecutionContext<'_, impl extensions::ShellExtensions>, + e: &BuiltinArgParseError, +) -> results::ExecutionResult { + if e.help_request { + let _ = writeln!(context.stdout(), "{}", e.message); + results::ExecutionResult::success() + } else { + let _ = writeln!(context.stderr(), "{}", e.message); + results::ExecutionExitCode::InvalidUsage.into() + } +} + fn exec_declaration_builtin< T: DeclarationCommand + Send + Sync, SE: extensions::ShellExtensions, @@ -510,24 +613,20 @@ async fn exec_declaration_builtin_impl< let mut options = vec![]; let mut declarations = vec![]; - for (i, arg) in args.into_iter().enumerate() { + // N.B. The first argument is the command name itself; it is skipped here. + for arg in args.into_iter().skip(1) { match arg { - CommandArg::String(s) - if i == 0 || (s.len() > 1 && (s.starts_with('-') || s.starts_with('+'))) => - { + CommandArg::String(s) if s.len() > 1 && (s.starts_with('-') || s.starts_with('+')) => { options.push(s); } _ => declarations.push(arg), } } - let result = T::new(options); + let result = parse_options_only::(options); let mut command = match result { Ok(command) => command, - Err(e) => { - let _ = writeln!(context.stderr(), "{e}"); - return Ok(results::ExecutionExitCode::InvalidUsage.into()); - } + Err(e) => return Ok(report_arg_parse_error(&context, &e)), }; command.set_declarations(declarations); @@ -570,3 +669,77 @@ async fn call_builtin( Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + + fn split(args: &[&str], shorts: &str) -> (Vec, Vec) { + split_option_section( + &args.iter().map(|s| s.to_string()).collect::>(), + shorts, + &[], + ) + } + + #[test] + fn test_split_option_section_stops_at_operand() { + let (options, trailing) = split(&["-n", "hi", "-e"], ""); + assert_eq!(options, ["-n"]); + assert_eq!(trailing, ["hi", "-e"]); + } + + #[test] + fn test_split_option_section_double_dash_is_preserved_in_trailing_args() { + let (options, trailing) = split(&["-n", "--", "-x"], ""); + assert_eq!(options, ["-n"]); + assert_eq!(trailing, ["--", "-x"]); + + // A lone trailing `--` is preserved as well. + let (options, trailing) = split(&["--"], ""); + assert_eq!(options.len(), 0); + assert_eq!(trailing, ["--"]); + } + + #[test] + fn test_split_option_section_value_taking_short_option() { + // Attached value. + let (options, trailing) = split(&["-d:", "rest"], "d"); + assert_eq!(options, ["-d:"]); + assert_eq!(trailing, ["rest"]); + + // Separate, flag-looking value. + let (options, trailing) = split(&["-d", "-x", "rest"], "d"); + assert_eq!(options, ["-d", "-x"]); + assert_eq!(trailing, ["rest"]); + } + + #[test] + fn test_split_option_section_plus_options() { + let (options, trailing) = split(&["+v", "+x", "foo"], ""); + assert_eq!(options, ["+v", "+x"]); + assert_eq!(trailing, ["foo"]); + + // A plus-style option group taking a separate value. + let (options, trailing) = split(&["+o", "optname", "foo"], "o"); + assert_eq!(options, ["+o", "optname"]); + assert_eq!(trailing, ["foo"]); + } + + #[test] + fn test_split_option_section_dash_is_an_operand() { + let (options, trailing) = split(&["-n", "-", "more"], ""); + assert_eq!(options, ["-n"]); + assert_eq!(trailing, ["-", "more"]); + } + + #[test] + fn test_expand_plus_option_groups() { + let args: Vec = ["-a", "+vx", "foo", "+o"] + .iter() + .map(|s| s.to_string()) + .collect(); + let expanded = expand_plus_option_groups(args); + assert_eq!(expanded, ["-a", "+v", "+x", "foo", "+o"]); + } +} diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs index 762be8cea..39b267dcc 100644 --- a/brush-core/src/completion.rs +++ b/brush-core/src/completion.rs @@ -1,6 +1,6 @@ //! Implements programmable command completion support. -use clap::ValueEnum; +use std::str::FromStr; use std::{ borrow::Cow, collections::HashMap, @@ -18,109 +18,77 @@ use crate::{ use brush_parser::unquote_str; /// Type of action to take to generate completion candidates. -#[derive(Clone, Debug, ValueEnum)] +#[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum CompleteAction { /// Complete with valid aliases. - #[clap(name = "alias")] Alias, /// Complete with names of array shell variables. - #[clap(name = "arrayvar")] ArrayVar, /// Complete with names of key bindings. - #[clap(name = "binding")] Binding, /// Complete with names of shell builtins. - #[clap(name = "builtin")] Builtin, /// Complete with names of executable commands. - #[clap(name = "command")] Command, /// Complete with directory names. - #[clap(name = "directory")] Directory, /// Complete with names of disabled shell builtins. - #[clap(name = "disabled")] Disabled, /// Complete with names of enabled shell builtins. - #[clap(name = "enabled")] Enabled, /// Complete with names of exported shell variables. - #[clap(name = "export")] Export, /// Complete with filenames. - #[clap(name = "file")] File, /// Complete with names of shell functions. - #[clap(name = "function")] Function, /// Complete with valid user groups. - #[clap(name = "group")] Group, /// Complete with names of valid shell help topics. - #[clap(name = "helptopic")] HelpTopic, /// Complete with the system's hostname(s). - #[clap(name = "hostname")] HostName, /// Complete with the command names of shell-managed jobs. - #[clap(name = "job")] Job, /// Complete with valid shell keywords. - #[clap(name = "keyword")] Keyword, /// Complete with the command names of running shell-managed jobs. - #[clap(name = "running")] Running, /// Complete with names of system services. - #[clap(name = "service")] Service, /// Complete with the names of options settable via shopt. - #[clap(name = "setopt")] SetOpt, /// Complete with the names of options settable via set -o. - #[clap(name = "shopt")] ShOpt, /// Complete with the names of trappable signals. - #[clap(name = "signal")] Signal, /// Complete with the command names of stopped shell-managed jobs. - #[clap(name = "stopped")] Stopped, /// Complete with valid usernames. - #[clap(name = "user")] User, /// Complete with names of shell variables. - #[clap(name = "variable")] Variable, } /// Options influencing how command completions are generated. -#[derive(Clone, Debug, Eq, Hash, PartialEq, ValueEnum)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum CompleteOption { /// Perform rest of default completions if no completions are generated. - #[clap(name = "bashdefault")] BashDefault, /// Use default filename completion if no completions are generated. - #[clap(name = "default")] Default, /// Treat completions as directory names. - #[clap(name = "dirnames")] DirNames, /// Treat completions as filenames. - #[clap(name = "filenames")] FileNames, /// Suppress default auto-quotation of completions. - #[clap(name = "noquote")] NoQuote, /// Do not sort completions. - #[clap(name = "nosort")] NoSort, /// Do not append a trailing space to completions at the end of the input line. - #[clap(name = "nospace")] NoSpace, /// Also generate directory completions. - #[clap(name = "plusdirs")] PlusDirs, } @@ -1526,6 +1494,72 @@ fn replace_unescaped_ampersands<'a>(pattern: &'a str, replacement: &str) -> Cow< result.into() } +impl CompleteAction { + /// Parses an action name (as used by `complete -A`). + pub fn parse(s: &str) -> Option { + Some(match s { + "alias" => Self::Alias, + "arrayvar" => Self::ArrayVar, + "binding" => Self::Binding, + "builtin" => Self::Builtin, + "command" => Self::Command, + "directory" => Self::Directory, + "disabled" => Self::Disabled, + "enabled" => Self::Enabled, + "export" => Self::Export, + "file" => Self::File, + "function" => Self::Function, + "group" => Self::Group, + "helptopic" => Self::HelpTopic, + "hostname" => Self::HostName, + "job" => Self::Job, + "keyword" => Self::Keyword, + "running" => Self::Running, + "service" => Self::Service, + "setopt" => Self::SetOpt, + "shopt" => Self::ShOpt, + "signal" => Self::Signal, + "stopped" => Self::Stopped, + "user" => Self::User, + "variable" => Self::Variable, + _ => return None, + }) + } +} + +impl FromStr for CompleteAction { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse(s).ok_or_else(|| format!("invalid completion action: `{s}`")) + } +} + +impl CompleteOption { + /// Parses a completion option name (as used by `complete -o`). + pub fn parse(s: &str) -> Option { + Some(match s { + "bashdefault" => Self::BashDefault, + "default" => Self::Default, + "dirnames" => Self::DirNames, + "filenames" => Self::FileNames, + "noquote" => Self::NoQuote, + "nosort" => Self::NoSort, + "nospace" => Self::NoSpace, + "plusdirs" => Self::PlusDirs, + _ => return None, + }) + } +} + +impl FromStr for CompleteOption { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse(s).ok_or_else(|| format!("invalid completion option: `{s}`")) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/brush-experimental-builtins/Cargo.toml b/brush-experimental-builtins/Cargo.toml index da567f1ad..da107e794 100644 --- a/brush-experimental-builtins/Cargo.toml +++ b/brush-experimental-builtins/Cargo.toml @@ -22,5 +22,5 @@ workspace = true [dependencies] brush-core = { version = "^0.5.0", path = "../brush-core" } -clap = { version = "4.6.0", features = ["derive", "wrap_help"] } +bpaf = { version = "0.9.27", features = ["derive", "bright-color"] } serde_json = { version = "1.0.149", optional = true } diff --git a/brush-experimental-builtins/src/save.rs b/brush-experimental-builtins/src/save.rs index 004eecca8..fc12bc768 100644 --- a/brush-experimental-builtins/src/save.rs +++ b/brush-experimental-builtins/src/save.rs @@ -1,16 +1,29 @@ +use bpaf::Bpaf; + use brush_core::{ExecutionResult, builtins}; -use clap::Parser; use std::io::Write; /// (*EXPERIMENTAL*) Serializes the current shell state to JSON and writes it to stdout. /// Beware that the serialized state may include sensitive information, such as any /// secrets stored in shell variables or referenced in command history. -#[derive(Parser)] +#[derive(Clone, Bpaf)] pub(crate) struct SaveCommand {} impl builtins::Command for SaveCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + bpaf::construct!(SaveCommand {}) + } + + fn about() -> &'static str { + "Serializes the current shell state to JSON and writes it to stdout." + } + + fn synopsis() -> &'static str { + "" + } + async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml index 5daeadd2e..69209913b 100644 --- a/brush-shell/Cargo.toml +++ b/brush-shell/Cargo.toml @@ -80,7 +80,7 @@ brush-core = { version = "^0.5.0", path = "../brush-core" } brush-builtins = { version = "^0.2.0", path = "../brush-builtins" } brush-coreutils-builtins = { version = "^0.1.0", path = "../brush-coreutils-builtins", optional = true } brush-experimental-builtins = { version = "^0.1.0", path = "../brush-experimental-builtins", optional = true } -clap = { version = "4.6.0", features = ["derive", "env"] } +bpaf = { version = "0.9.27", features = ["derive", "bright-color"] } color-print = "0.3.7" etcetera = "0.11.0" schemars = { version = "1.2.1", optional = true } @@ -129,7 +129,6 @@ indexmap = "2.13.0" junit-report = "0.9.0" os-release = "0.1.0" predicates = "3.1.4" -pretty_assertions = { version = "1.4.1", features = ["unstable"] } regex = "1.12.3" serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" diff --git a/brush-shell/src/args.rs b/brush-shell/src/args.rs index a924790db..91d2332fb 100644 --- a/brush-shell/src/args.rs +++ b/brush-shell/src/args.rs @@ -1,10 +1,10 @@ //! Types for brush command-line parsing. -use clap::{Parser, builder::styling}; +use crate::{events, productinfo}; +use bpaf::Parser; use std::io::IsTerminal; use std::path::PathBuf; - -use crate::{events, productinfo}; +use std::str::FromStr; const SHORT_DESCRIPTION: &str = "Bo[u]rn[e] RUsty SHell 🦀 (https://brush.sh)"; @@ -14,10 +14,6 @@ brush is distributed under the terms of the MIT license. If you encounter any is For more information, visit https://brush.sh."; -const USAGE: &str = color_print::cstr!( - "brush [OPTIONS]... [SCRIPT_PATH [SCRIPT_ARGS]...]" -); - const VERSION: &str = const_format::concatcp!( productinfo::PRODUCT_VERSION, " (", @@ -25,16 +21,8 @@ const VERSION: &str = const_format::concatcp!( ")" ); -const HEADING_STANDARD_OPTIONS: &str = "Standard shell options"; - -const HEADING_CONFIG_OPTIONS: &str = "Configuration options"; - -const HEADING_UI_OPTIONS: &str = "User interface options"; - -const HEADING_EXPERIMENTAL_OPTIONS: &str = "*Experimental* options (unstable)"; - /// Identifies the input backend to use for the shell. -#[derive(Clone, Copy, clap::ValueEnum)] +#[derive(Clone, Copy, Debug)] pub enum InputBackendType { /// Richest input backend, based on reedline. Reedline, @@ -44,209 +32,388 @@ pub enum InputBackendType { Minimal, } +impl FromStr for InputBackendType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "reedline" => Ok(Self::Reedline), + "basic" => Ok(Self::Basic), + "minimal" => Ok(Self::Minimal), + _ => Err(format!( + "invalid input backend: `{s}` (expected one of reedline, basic, minimal)" + )), + } + } +} + /// Parsed command-line arguments for the brush shell. -#[derive(Clone, Parser)] -#[clap(name = productinfo::PRODUCT_NAME, - version = VERSION, - about = SHORT_DESCRIPTION, - long_about = LONG_DESCRIPTION, - author, - override_usage = USAGE, - disable_help_flag = true, - disable_version_flag = true, - styles = brush_help_styles())] +#[derive(Clone, Debug, Default)] pub struct CommandLineArgs { - /// Display usage information. - #[clap(long = "help", action = clap::ArgAction::HelpShort)] - pub help: Option, - - /// Display shell version. - #[clap(long = "version", action = clap::ArgAction::Version)] - pub version: Option, - /// Path to TOML-based `brush` config file (overrides default location). - #[clap(long = "config", value_name = "FILE", help_heading = HEADING_CONFIG_OPTIONS)] pub config_file: Option, /// Disable loading of TOML-based `brush` config file. - #[clap(long = "no-config", help_heading = HEADING_CONFIG_OPTIONS)] pub no_config: bool, /// Enable `noclobber` shell option. - #[arg(short = 'C', help_heading = HEADING_STANDARD_OPTIONS)] pub disallow_overwriting_regular_files_via_output_redirection: bool, /// Execute the provided command and then exit. - #[arg(short = 'c', value_name = "COMMAND", help_heading = HEADING_STANDARD_OPTIONS)] pub command: Option, /// Enable error-on-exit behavior. - #[clap(short = 'e', help_heading = HEADING_STANDARD_OPTIONS)] pub exit_on_nonzero_command_exit: bool, /// Disable pathname expansion (also known as filename globbing). - #[clap(short = 'f', help_heading = HEADING_STANDARD_OPTIONS)] pub disable_pathname_expansion: bool, /// Run in interactive mode. - #[clap(short = 'i', help_heading = HEADING_STANDARD_OPTIONS)] pub interactive: bool, /// Inherit the specified file descriptors injected by the parent process. - #[clap(long = "inherit-fd", value_name = "FD", help_heading = HEADING_STANDARD_OPTIONS)] pub inherited_fds: Vec, /// Make shell act as if it had been invoked as a login shell. - #[clap(short = 'l', long = "login", help_heading = HEADING_STANDARD_OPTIONS)] - pub login: bool, + pub login: Option, /// Do not execute commands. - #[clap(short = 'n', help_heading = HEADING_STANDARD_OPTIONS)] pub do_not_execute_commands: bool, /// Don't use readline for input. - #[clap(long = "noediting", help_heading = HEADING_STANDARD_OPTIONS)] pub no_editing: bool, /// Don't process any profile/login files (`/etc/profile`, `~/.bash_profile`, `~/.bash_login`, /// `~/.profile`). - #[clap(long = "noprofile", help_heading = HEADING_STANDARD_OPTIONS)] pub no_profile: bool, /// Don't process "rc" files if the shell is interactive (e.g., `~/.bashrc`, `~/.brushrc`). - #[clap(long = "norc", help_heading = HEADING_STANDARD_OPTIONS)] pub no_rc: bool, /// Don't inherit environment variables from the calling process. - #[clap(long = "noenv", help_heading = HEADING_STANDARD_OPTIONS)] pub do_not_inherit_env: bool, /// Enable option (`set -o` option). - #[clap(short = 'o', value_name = "OPTION", help_heading = HEADING_STANDARD_OPTIONS)] pub enabled_options: Vec, /// Disable option (`set -o` option). - #[clap(long = "+o", value_name = "OPTION", hide = true, help_heading = HEADING_STANDARD_OPTIONS)] pub disabled_options: Vec, /// Enable `shopt` option. - #[clap(short = 'O', value_name = "SHOPT_OPTION", help_heading = HEADING_STANDARD_OPTIONS)] pub enabled_shopt_options: Vec, /// Disable `shopt` option. - #[clap(long = "+O", value_name = "SHOPT_OPTION", hide = true, help_heading = HEADING_STANDARD_OPTIONS)] pub disabled_shopt_options: Vec, /// Disable non-POSIX extensions. - #[clap(long = "posix", help_heading = HEADING_STANDARD_OPTIONS)] pub posix: bool, /// Path to the rc file to load in interactive shells (instead of `bash.bashrc` and /// `~/.bashrc`). - #[clap(long = "rcfile", alias = "init-file", value_name = "FILE", help_heading = HEADING_STANDARD_OPTIONS)] pub rc_file: Option, /// Read commands from standard input. - #[clap(short = 's', help_heading = HEADING_STANDARD_OPTIONS)] pub read_commands_from_stdin: bool, /// Run in `sh` compatibility mode, as if run as `/bin/sh`. - #[clap(long = "sh")] pub sh_mode: bool, /// Run only one command and then exit. - #[clap(short = 't', help_heading = HEADING_STANDARD_OPTIONS)] pub exit_after_one_command: bool, /// Treat expansion of an unset variable as an error. - #[clap(short = 'u', help_heading = HEADING_STANDARD_OPTIONS)] pub treat_unset_variables_as_error: bool, /// Print input when it's processed. - #[clap(short = 'v', long = "verbose", help_heading = HEADING_STANDARD_OPTIONS)] - pub verbose: bool, + pub verbose: Option, /// Print commands as they execute. - #[clap(short = 'x', help_heading = HEADING_STANDARD_OPTIONS)] pub print_commands_and_arguments: bool, /// Enable xtrace and configure for the given output file. - #[clap(long = "xtrace-file", value_name = "FILE", help_heading = HEADING_UI_OPTIONS)] pub xtrace_file_path: Option, /// Disable bracketed paste. - #[clap(long = "disable-bracketed-paste", help_heading = HEADING_UI_OPTIONS)] pub disable_bracketed_paste: bool, /// Disable colorized output. - #[clap(long = "disable-color", help_heading = HEADING_UI_OPTIONS)] pub disable_color: bool, /// Enable syntax highlighting in input. - #[clap(long = "enable-highlighting", help_heading = HEADING_UI_OPTIONS, default_value_t = crate::entry::DEFAULT_ENABLE_HIGHLIGHTING)] pub enable_highlighting: bool, /// Enable experimental parser (not ready for use). - #[cfg(feature = "experimental-parser")] - #[clap(long = "experimental-parser", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] pub experimental_parser: bool, /// Enable terminal integration (**experimental**). - #[clap(long = "enable-terminal-integration", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] pub terminal_shell_integration: bool, /// Enable zsh-style preexec/precmd hooks (**experimental**). - #[clap(long = "enable-zsh-hooks", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] pub zsh_style_hooks: bool, /// Input backend. - #[clap(long = "input-backend", value_name = "BACKEND", help_heading = HEADING_UI_OPTIONS)] pub input_backend: Option, /// Load state from the given file; the saved state should be in JSON format /// and overrides any non-UI command-line options provided. - #[cfg(feature = "experimental-load")] - #[clap(long = "load", value_name = "FILE", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] pub load_file: Option, /// Enable debug logging for classes of tracing events. - #[clap(long = "debug", alias = "log-enable", value_name = "EVENT", help_heading = HEADING_UI_OPTIONS)] pub enabled_debug_events: Vec, /// Disable logging for classes of tracing events (takes same event types as `--debug`). - #[clap( - long = "disable-event", - alias = "log-disable", - value_name = "EVENT", - hide_possible_values = true, - help_heading = HEADING_UI_OPTIONS - )] pub disabled_events: Vec, /// Path and arguments for script to execute (optional). - #[clap( - trailing_var_arg = true, - allow_hyphen_values = false, - value_name = "SCRIPT_PATH [SCRIPT_ARGS]..." - )] pub script_args: Vec, } +/// If the `-c` group's command string is itself `--`, attaches it to the flag +/// (`-c=--`) so the parser does not mistake it for an option separator. Any +/// leading boolean characters in a combined group are split out into their own +/// flags first. +fn merge_dash_dash_value(options: &mut Vec, c_idx: usize, has_value: bool) { + if has_value && options.len() == c_idx + 2 && options.last().map(String::as_str) == Some("--") { + let _ = options.pop(); + let flag = options.pop().unwrap_or_else(|| String::from("-c")); + if let Some(group) = flag.strip_prefix('-').filter(|g| !g.is_empty()) { + for c in group.chars().take(group.chars().count() - 1) { + options.push(format!("-{c}")); + } + } + options.push(String::from("-c=--")); + } +} + +/// Short options that take a separate value token; used when deciding where +/// the option section of the command line ends. +const VALUE_TAKING_SHORT_OPTIONS: &str = "coO"; + +/// Boolean short options; used to detect `-c` at the tail end of a combined +/// group of options. +const BOOLEAN_SHORT_OPTIONS: &str = "Cefilnstuvx"; + +/// Long options that take a separate value token; used when deciding where +/// the option section of the command line ends. +const VALUE_TAKING_LONG_OPTIONS: &[&str] = &[ + "--config", + "--inherit-fd", + "--rcfile", + "--init-file", + "--xtrace-file", + "--input-backend", + "--load", + "--debug", + "--log-enable", + "--disable-event", + "--log-disable", +]; + impl CommandLineArgs { - /// Returns a `CommandLineArgs` with all clap-defined default values. + /// Returns a parser for the brush shell's command-line arguments. + /// + /// N.B. Only the leading option section is interpreted here; any script + /// path and arguments are captured verbatim by [`CommandLineArgs::try_parse_from`]. + #[must_use] + #[expect(clippy::too_many_lines, reason = "one block per command-line option")] + pub fn parser() -> impl Parser { + let config_file = long_config("config") + .help("Path to TOML-based `brush` config file (overrides default location).") + .optional(); + let no_config = long_flag( + "no-config", + "Disable loading of TOML-based `brush` config file.", + ); + let disallow_overwriting_regular_files_via_output_redirection = bpaf::short('C') + .help("Enable `noclobber` shell option.") + .switch(); + let command = bpaf::short('c') + .help("Execute the provided command and then exit.") + .argument::("COMMAND") + .optional(); + let exit_on_nonzero_command_exit = bpaf::short('e') + .help("Enable error-on-exit behavior.") + .switch(); + let disable_pathname_expansion = bpaf::short('f') + .help("Disable pathname expansion (also known as filename globbing).") + .switch(); + let interactive = bpaf::short('i').help("Run in interactive mode.").switch(); + let inherit_fd = long_option("inherit-fd") + .help("Inherit the specified file descriptors injected by the parent process.") + .argument::("FD"); + + let login = bpaf::short('l') + .long("login") + .help("Make shell act as if it had been invoked as a login shell.") + .req_flag(()) + .map(|(): ()| Some(true)) + .fallback(None); + let do_not_execute_commands = bpaf::short('n').help("Do not execute commands.").switch(); + let no_editing = long_flag("noediting", "Don't use readline for input."); + let no_profile = long_flag( + "noprofile", + "Don't process any profile/login files (`/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`).", + ); + let no_rc = long_flag( + "norc", + "Don't process \"rc\" files if the shell is interactive (e.g., `~/.bashrc`, `~/.brushrc`).", + ); + let do_not_inherit_env = long_flag( + "noenv", + "Don't inherit environment variables from the calling process.", + ); + + let enabled_options = repeated_value( + bpaf::short('o'), + "OPTION", + "Enable option (`set -o` option)", + ); + let enabled_shopt_options = + repeated_value(bpaf::short('O'), "SHOPT_OPTION", "Enable `shopt` option."); + let posix = long_flag("posix", "Disable non-POSIX extensions."); + let rc_file = long_option("rcfile") + .long("init-file") + .help("Path to the rc file to load in interactive shells.") + .argument::("FILE") + .optional(); + let read_commands_from_stdin = bpaf::short('s') + .help("Read commands from standard input.") + .switch(); + + let sh_mode = bpaf::long("sh") + .help("Run in `sh` compatibility mode, as if run as `/bin/sh`.") + .switch(); + let exit_after_one_command = bpaf::short('t') + .help("Run only one command and then exit.") + .switch(); + let treat_unset_variables_as_error = bpaf::short('u') + .help("Treat expansion of an unset variable as an error.") + .switch(); + let verbose = bpaf::short('v') + .long("verbose") + .help("Print input when it's processed.") + .req_flag(()) + .map(|(): ()| Some(true)) + .fallback(None); + let print_commands_and_arguments = bpaf::short('x') + .help("Print commands as they execute.") + .switch(); + + let xtrace_file_path = long_option("xtrace-file") + .help("Enable xtrace and configure for the given output file.") + .argument::("FILE") + .optional(); + + let disable_bracketed_paste = + long_flag("disable-bracketed-paste", "Disable bracketed paste."); + let disable_color = long_flag("disable-color", "Disable colorized output."); + let enable_highlighting = bpaf::long("enable-highlighting") + .help("Enable syntax highlighting in input.") + .switch() + .fallback(crate::entry::DEFAULT_ENABLE_HIGHLIGHTING); + + #[cfg(feature = "experimental-parser")] + let experimental_parser = bpaf::long("experimental-parser") + .help("Enable experimental parser (not ready for use).") + .switch(); + #[cfg(not(feature = "experimental-parser"))] + let experimental_parser = pure_default(false); + + let terminal_shell_integration = bpaf::long("enable-terminal-integration") + .help("Enable terminal integration (**experimental**).") + .switch(); + let zsh_style_hooks = bpaf::long("enable-zsh-hooks") + .help("Enable zsh-style preexec/precmd hooks (**experimental**).") + .switch(); + + let input_backend = long_option("input-backend") + .argument::("BACKEND") + .help("Input backend.") + .optional(); + + #[cfg(feature = "experimental-load")] + let load_file = bpaf::long("load") + .help("Load state from the given file; the saved state should be in JSON format and overrides any non-UI command-line options provided.") + .argument::("FILE") + .optional(); + #[cfg(not(feature = "experimental-load"))] + let load_file = pure_default(None::); + + let enabled_debug_events = long_option("debug") + .long("log-enable") + .help("Enable debug logging for classes of tracing events.") + .argument::("EVENT") + .many() + .fallback(Vec::new()); + let disabled_events = long_option("disable-event") + .long("log-disable") + .help("Disable logging for classes of tracing events.") + .argument::("EVENT") + .many() + .fallback(Vec::new()); + + let inherited_fds = inherit_fd.many().fallback(Vec::new()); + + // N.B. These use `any`/`literal`-based parsers, which bpaf requires to + // be positioned to the right of all other named options. + let disabled_options = plus_repeated_value("+o", "Disable option (`set -o` option)."); + let disabled_shopt_options = plus_repeated_value("+O", "Disable `shopt` option."); + + let script_args = pure_default(Vec::new()); + + bpaf::construct!(CommandLineArgs { + config_file, + no_config, + disallow_overwriting_regular_files_via_output_redirection, + command, + exit_on_nonzero_command_exit, + disable_pathname_expansion, + interactive, + inherited_fds, + login, + do_not_execute_commands, + no_editing, + no_profile, + no_rc, + do_not_inherit_env, + enabled_options, + enabled_shopt_options, + posix, + rc_file, + read_commands_from_stdin, + sh_mode, + exit_after_one_command, + treat_unset_variables_as_error, + verbose, + print_commands_and_arguments, + xtrace_file_path, + disable_bracketed_paste, + disable_color, + enable_highlighting, + experimental_parser, + terminal_shell_integration, + zsh_style_hooks, + input_backend, + load_file, + enabled_debug_events, + disabled_events, + disabled_options, + disabled_shopt_options, + script_args, + }) + } + + /// Returns a `CommandLineArgs` with all default values. /// /// This is useful for detecting which CLI arguments were explicitly provided /// vs. which retained their default values (e.g., for config file merging). + /// # Panics + /// + /// Panics if the default arguments fail to parse, which should be + /// impossible. #[must_use] - #[allow( - clippy::missing_panics_doc, - reason = "parsing defaults should not panic" - )] pub fn default_values() -> Self { - use clap::Parser; - // Parse with just the program name to get all defaults. - // This won't fail because all arguments have defaults or are optional. - #[allow(clippy::expect_used)] + #[expect(clippy::expect_used, reason = "parsing defaults should not panic")] Self::try_parse_from(["brush"]).expect("parsing defaults should never fail") } @@ -271,20 +438,160 @@ impl CommandLineArgs { // In all other cases, we assume interactive mode. true } + + /// Returns the shell's argument parser wrapped with standard help/version + /// handling. + #[must_use] + pub fn option_parser() -> bpaf::OptionParser { + Self::parser() + .to_options() + .version(VERSION) + .descr(LONG_DESCRIPTION) + } + + /// Parses the brush shell's command-line arguments from the given list. + /// + /// This is a bash-faithful interpretation of the command line: + /// + /// * Options are parsed up to the first operand or `--`; everything after + /// that becomes `script_args` verbatim. + /// * A `--` immediately following `-c` (or a combined group ending in `-c`) + /// acts as an option terminator, with the command string taken from the + /// next argument. + /// + /// # Arguments + /// + /// * `args` - The arguments, including the program name. + pub fn try_parse_from>( + args: impl IntoIterator, + ) -> Result { + let mut args: Vec = args.into_iter().map(Into::into).collect(); + if !args.is_empty() { + args.remove(0); // program name + } + + // In bash, once `-c` consumes its command string, all remaining + // arguments become positional script arguments verbatim; notably a + // following `--` becomes `$0` rather than acting as an option + // terminator. Handle that by ending the option section right after + // the `-c` value when a pending `-c` is present. + // A `--` directly following the `-c` group is an option terminator: + // the command string is taken from the next argument. + if let Some(dd_idx) = args.iter().position(|a| a == "--") { + if dd_idx > 0 && pending_c_group(&args[dd_idx - 1]) { + args.remove(dd_idx); + let c_idx = dd_idx - 1; + let has_value = c_idx + 1 < args.len(); + + let mut options: Vec = args[..=(c_idx + 1).min(args.len() - 1)].to_vec(); + let trailing: Vec = if has_value { + args[c_idx + 2..].to_vec() + } else { + Vec::new() + }; + + merge_dash_dash_value(&mut options, c_idx, has_value); + return finish_parsing(&options, trailing); + } + } + + let first_dd = args.iter().position(|a| a == "--"); + let c_candidate = args + .iter() + .take(first_dd.unwrap_or(args.len())) + .rposition(|a| pending_c_group(a)); + + if let Some(c_idx) = c_candidate { + let has_value = c_idx + 1 < args.len(); + + // Include the `-c` group and its value in the option section. + let mut options: Vec = args[..=(c_idx + 1).min(args.len() - 1)].to_vec(); + let trailing: Vec = if has_value { + args[c_idx + 2..].to_vec() + } else { + Vec::new() + }; + + merge_dash_dash_value(&mut options, c_idx, has_value); + + return finish_parsing(&options, trailing); + } + + let (options, trailing) = brush_core::builtins::split_option_section( + &args, + VALUE_TAKING_SHORT_OPTIONS, + VALUE_TAKING_LONG_OPTIONS, + ); + + finish_parsing(&options, trailing) + } } -/// Returns clap styling to be used for command-line help. -#[doc(hidden)] -fn brush_help_styles() -> clap::builder::Styles { - styling::Styles::styled() - .header( - styling::AnsiColor::Yellow.on_default() - | styling::Effects::BOLD - | styling::Effects::UNDERLINE, - ) - .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD) - .literal(styling::AnsiColor::Magenta.on_default() | styling::Effects::BOLD) - .placeholder(styling::AnsiColor::Cyan.on_default()) +fn finish_parsing( + options: &[String], + trailing: Vec, +) -> Result { + let mut parsed = CommandLineArgs::option_parser().run_inner(options)?; + + parsed.script_args = trailing; + + Ok(parsed) +} + +/// Returns whether `arg` is `-c` or a combined short-flag group ending in `c` +/// (like `-ec`) where all preceding characters are boolean flags. +fn pending_c_group(arg: &str) -> bool { + let Some(flags) = arg.strip_prefix('-') else { + return false; + }; + let Some(preceding) = flags.strip_suffix('c') else { + return false; + }; + preceding + .chars() + .all(|ch| BOOLEAN_SHORT_OPTIONS.contains(ch)) +} + +/// A hidden boolean flag with the given long name. +fn long_flag(name: &'static str, help: &'static str) -> impl Parser { + bpaf::long(name).help(help).switch() +} + +/// A long option with the given name. +fn long_option(name: &'static str) -> bpaf::parsers::NamedArg { + bpaf::long(name) +} + +/// Like [`long_option`] but named `--config`. +fn long_config(name: &'static str) -> bpaf::parsers::ParseArgument { + long_option(name).argument::("FILE") +} + +/// A repeatable value-taking option attached to the given named argument. +fn repeated_value( + arg: bpaf::parsers::NamedArg, + meta: &'static str, + help: &'static str, +) -> impl Parser> { + arg.help(help) + .argument::(meta) + .many() + .fallback(Vec::new()) +} + +/// A repeatable plus-style option (e.g., `+o OPTION`) that disables something. +fn plus_repeated_value(plus_form: &'static str, help: &'static str) -> impl Parser> { + let tag = bpaf::literal(plus_form).help(help); + let value = bpaf::any::("OPTION", Some); + bpaf::construct!(tag, value) + .adjacent() + .many() + .map(|pairs| pairs.into_iter().map(|((), v)| v).collect()) +} + +/// A parser that always succeeds with the given value without consuming anything. +fn pure_default(value: T) -> impl Parser { + bpaf::pure(value) } #[cfg(test)] @@ -293,11 +600,68 @@ mod tests { #[test] fn test_default_values() { - let args = CommandLineArgs::default_values(); - // Verify some basic defaults + let args = CommandLineArgs::try_parse_from(["brush"]).unwrap(); assert!(!args.interactive); - assert!(!args.login); + assert_eq!(args.login, None); assert!(args.command.is_none()); assert!(args.script_args.is_empty()); } + + #[test] + fn parse_script_and_args() { + let parsed_args = + CommandLineArgs::try_parse_from(["brush", "some-script", "-x", "1", "--option"]) + .unwrap(); + assert_eq!( + parsed_args.script_args, + ["some-script", "-x", "1", "--option"] + ); + } + + #[test] + fn parse_unknown_args() { + let result = CommandLineArgs::try_parse_from(["brush", "--unknown-option"]); + assert!(result.is_err()); + } + + #[test] + fn parse_c_with_double_dash_separator() { + let parsed_args = + CommandLineArgs::try_parse_from(["brush", "-c", "--", "echo hello", "arg0"]).unwrap(); + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert_eq!(parsed_args.script_args, ["arg0"]); + } + + #[test] + fn parse_c_with_double_dash_no_command() { + assert!(CommandLineArgs::try_parse_from(["brush", "-c", "--"]).is_err()); + } + + #[test] + fn parse_ec_with_double_dash_separator() { + let parsed_args = + CommandLineArgs::try_parse_from(["brush", "-ec", "--", "echo hello", "arg0"]).unwrap(); + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert_eq!(parsed_args.script_args, ["arg0"]); + } + + #[test] + fn parse_o_with_double_dash_is_error() { + // bash's -o consumes -- as its literal value (invalid option name), so + // this must not be treated as a terminator for -o. + let result = CommandLineArgs::try_parse_from(["brush", "-o", "--"]); + assert!(result.is_err()); + } + + #[test] + fn parse_bool_flag_before_double_dash_not_transformed() { + // -e is a boolean flag, not -c. The -- terminates options; everything + // after becomes positional (including -c). + let parsed_args = + CommandLineArgs::try_parse_from(["brush", "-e", "--", "-c", "echo"]).unwrap(); + assert!(parsed_args.command.is_none()); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert_eq!(parsed_args.script_args, ["--", "-c", "echo"]); + } } diff --git a/brush-shell/src/brushctl.rs b/brush-shell/src/brushctl.rs index 509e04e87..3f3fc6ef9 100644 --- a/brush-shell/src/brushctl.rs +++ b/brush-shell/src/brushctl.rs @@ -1,5 +1,5 @@ +use bpaf::Bpaf; use brush_core::{ExecutionResult, sys}; -use clap::{Parser, Subcommand}; use std::io::Write; use crate::events; @@ -29,146 +29,161 @@ impl, - /// The input line to generate completions for. + #[bpaf(positional("LINE"))] line: String, }, + /// Display the current call stack. + #[bpaf(command("call"))] + Call { + #[bpaf(external(show_call_stack), hide)] + show_call_stack: ShowCallStack, + }, + /// Configure tracing events. + #[bpaf(command("events"))] + Events { + #[bpaf(external(events_action))] + events_action: EventsAction, + }, + /// Inspect process state. + #[bpaf(command("process"))] + Process { + #[bpaf(external(process_info), hide)] + process_info: ProcessInfo, + }, +} + +/// Commands for displaying the current call stack. +#[derive(Clone, Bpaf, Debug)] +pub(crate) struct ShowCallStack { + /// Whether to show more details. + #[bpaf(short('d'), long("detailed"))] + detailed: bool, } /// Commands for configuring tracing events. -#[derive(Subcommand)] -enum EventsCommand { +#[derive(Clone, Bpaf, Debug)] +pub(crate) enum EventsAction { /// Display status of enabled events. + #[bpaf(command("status"))] Status, - /// Enable event. + #[bpaf(command("enable"))] Enable { /// Event to enable. + #[bpaf(positional("EVENT"))] event: events::TraceEvent, }, - /// Disable event. + #[bpaf(command("disable"))] Disable { /// Event to disable. + #[bpaf(positional("EVENT"))] event: events::TraceEvent, }, } /// Commands for inspecting process state. -#[expect(clippy::enum_variant_names)] -#[derive(Subcommand)] -enum ProcessCommand { +#[derive(Clone, Bpaf, Debug)] +pub(crate) enum ProcessInfo { /// Display process ID. - #[clap(name = "pid")] - ShowProcessId, + #[bpaf(command("pid"))] + Pid, /// Display process group ID. - #[clap(name = "pgid")] - ShowProcessGroupId, + #[bpaf(command("pgid"))] + Pgid, /// Display foreground process ID. - #[clap(name = "fgpid")] - ShowForegroundProcessId, + #[bpaf(command("fgpid"))] + Fgpid, /// Display parent process ID. - #[clap(name = "ppid")] - ShowParentProcessId, + #[bpaf(command("ppid"))] + Ppid, +} + +pub(crate) struct BrushCtlCommand { + command_group: CommandGroup, +} + +impl BrushCtlCommand { + pub(crate) const fn new(group: CommandGroup) -> Self { + Self { + command_group: group, + } + } } impl brush_core::builtins::Command for BrushCtlCommand { type Error = brush_core::Error; + fn parser() -> impl bpaf::Parser { + let command_group = command_group(); + bpaf::construct!(BrushCtlCommand { command_group }) + } + + fn about() -> &'static str { + "Configure the running brush shell." + } + async fn execute( &self, mut context: brush_core::ExecutionContext<'_, SE>, ) -> Result { match &self.command_group { - CommandGroup::Call(call) => call.execute(&context), - CommandGroup::Complete(complete) => complete.execute(&mut context).await, - CommandGroup::Events(events) => events.execute(&context), - CommandGroup::Process(process) => process.execute(&context), + CommandGroup::Call { show_call_stack } => show_call_stack.execute(&context), + CommandGroup::Complete { cursor_index, line } => { + execute_complete_line(&mut context, *cursor_index, line).await + } + CommandGroup::Events { events_action } => events_action.execute(&context), + CommandGroup::Process { process_info } => process_info.execute(&context), } } } -impl CallCommand { +impl ShowCallStack { fn execute( &self, context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, ) -> Result { - match self { - Self::ShowCallStack { detailed } => { - let stack = context.shell.call_stack(); - let format_options = brush_core::callstack::FormatOptions { - show_args: *detailed, - show_entry_points: *detailed, - }; + let Self { detailed } = self; + { + let stack = context.shell.call_stack(); + let format_options = brush_core::callstack::FormatOptions { + show_args: *detailed, + show_entry_points: *detailed, + }; - write!(context.stdout(), "{}", stack.format(&format_options))?; + write!(context.stdout(), "{}", stack.format(&format_options))?; - Ok(ExecutionResult::success()) - } + Ok(ExecutionResult::success()) } } } -impl CompleteCommand { - async fn execute( - &self, - context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, - ) -> Result { - match self { - Self::Line { cursor_index, line } => { - let completions = context - .shell - .complete(line, cursor_index.unwrap_or(line.len())) - .await?; - for candidate in completions.candidates { - writeln!(context.stdout(), "{candidate}")?; - } - Ok(ExecutionResult::success()) - } - } +async fn execute_complete_line( + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + cursor_index: Option, + line: &str, +) -> Result { + let completions = context + .shell + .complete(line, cursor_index.unwrap_or(line.len())) + .await?; + for candidate in completions.candidates { + writeln!(context.stdout(), "{candidate}")?; } + + Ok(ExecutionResult::success()) } -impl EventsCommand { +impl EventsAction { fn execute( &self, context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, @@ -200,17 +215,17 @@ impl EventsCommand { } } -impl ProcessCommand { +impl ProcessInfo { fn execute( &self, context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, ) -> Result { match self { - Self::ShowProcessId => { + Self::Pid => { writeln!(context.stdout(), "{}", std::process::id())?; Ok(ExecutionResult::success()) } - Self::ShowProcessGroupId => { + Self::Pgid => { if let Some(pgid) = sys::terminal::get_process_group_id() { writeln!(context.stdout(), "{pgid}")?; Ok(ExecutionResult::success()) @@ -219,7 +234,7 @@ impl ProcessCommand { Ok(ExecutionResult::general_error()) } } - Self::ShowForegroundProcessId => { + Self::Fgpid => { if let Some(pid) = sys::terminal::get_foreground_pid() { writeln!(context.stdout(), "{pid}")?; Ok(ExecutionResult::success()) @@ -228,7 +243,7 @@ impl ProcessCommand { Ok(ExecutionResult::general_error()) } } - Self::ShowParentProcessId => { + Self::Ppid => { if let Some(pid) = sys::terminal::get_parent_process_id() { writeln!(context.stdout(), "{pid}")?; Ok(ExecutionResult::success()) diff --git a/brush-shell/src/config.rs b/brush-shell/src/config.rs index b50a71e2d..4e1a7683f 100644 --- a/brush-shell/src/config.rs +++ b/brush-shell/src/config.rs @@ -283,7 +283,6 @@ pub fn load_config(disabled: bool, explicit_path: Option<&Path>) -> ConfigLoadRe #[cfg(test)] mod tests { use super::*; - use clap::Parser; #[test] fn empty_config() { diff --git a/brush-shell/src/entry.rs b/brush-shell/src/entry.rs index 15340add4..063209dc1 100644 --- a/brush-shell/src/entry.rs +++ b/brush-shell/src/entry.rs @@ -11,7 +11,6 @@ use crate::productinfo; use brush_builtins::ShellBuilderExt as _; #[cfg(feature = "experimental-builtins")] use brush_experimental_builtins::ShellBuilderExt as _; -use clap::CommandFactory; use std::sync::LazyLock; use std::{path::Path, sync::Arc}; use tokio::sync::Mutex; @@ -25,95 +24,32 @@ static TRACE_EVENT_CONFIG: LazyLock; type BrushShell = brush_core::Shell; -// WARN: this implementation shadows `clap::Parser::parse_from` one so it must be defined -// after the `use clap::Parser` -impl CommandLineArgs { - // Work around clap's limitation handling `--` like a regular value - // TODO(cmdline): We can safely remove this `impl` after the issue is resolved - // https://github.com/clap-rs/clap/issues/5055 - // This function takes precedence over [`clap::Parser::parse_from`] - fn try_parse_from(itr: impl IntoIterator) -> Result { - let mut args: Vec = itr.into_iter().collect(); - - // In bash, `-c` treats `--` as an option terminator and takes its - // command string from the first argument *after* `--`. (Other - // value-taking flags like `-o` and `-O` instead consume `--` as their - // literal value in bash, rejecting it as an invalid option name.) - // - // Remove the `--` so that `-c` naturally consumes the next token as its - // value via clap. Other value-taking flags are unaffected: for them - // try_parse_known splits at `--` before clap sees it, so they still - // produce an error for invocations like `-o --`/`-O --` (via a missing - // value rather than an invalid option name). In both cases, we - // intentionally do not treat `--` as an option terminator for those - // flags. - if let Some(dd_idx) = args.iter().position(|a| a == "--") { - if let Some(flag_idx) = dd_idx - .checked_sub(1) - .filter(|&i| Self::has_pending_c_flag(&args[i])) - { - // Remove the option-terminating `--`. - args.remove(dd_idx); - - // If the command value (now at dd_idx) is itself `--`, merge it - // into the flag as an attached value (e.g., "-c" + "--" → "-c--"). - // Clap parses `-c--` as `-c` with value `"--"` (standard POSIX - // short-option-with-attached-value syntax). This prevents - // try_parse_known from splitting at it again. - if args.get(dd_idx).map(String::as_str) == Some("--") { - let value = args.remove(dd_idx); - args[flag_idx].push_str(&value); - } - } - } - - let (mut this, script_args) = brush_core::builtins::try_parse_known::(args)?; - - // Collect any args from after `--` (handled by try_parse_known) into - // script_args, which become positional parameters ($0, $1, ...). - if let Some(args) = script_args { - this.script_args.extend(args); - } - - Ok(this) - } +/// Maximum width used when rendering help and usage messages; mirrors the +/// default width used by `bpaf`. +const PARSE_FAILURE_MAX_WIDTH: usize = 100; - /// Returns true if `arg` is `-c` or a combined short-flag group ending in - /// `c` (like `-ec`) where all preceding characters are boolean flags. - /// - /// This specifically targets `-c` because it is the only short flag with - /// special `--` option-terminator behavior in bash. Other value-taking flags - /// (`-o`, `-O`) consume `--` as their literal value instead. - /// - /// Uses clap's argument definitions to validate preceding flags, avoiding - /// a hardcoded list of boolean flag characters. - fn has_pending_c_flag(arg: &str) -> bool { - // Must be a short flag group ending in 'c': "-c", "-ec", "-xec", etc. - let Some(flags) = arg.strip_prefix('-') else { - return false; - }; - let Some(preceding) = flags.strip_suffix('c') else { - return false; - }; - // Reject long-option-like args (e.g., "--c"). - if preceding.starts_with('-') { - return false; - } - - // For "-c" alone, preceding is empty and the check below is vacuously - // true. For combined flags like `-ec`, verify all chars before the - // trailing `c` are boolean flags. If any preceding char takes a value - // (like `o`), then `c` is consumed as that flag's value, not as `-c`. - let cmd = Self::command(); - preceding.chars().all(|ch| { - cmd.get_arguments().any(|a| { - a.get_short() == Some(ch) - && !matches!( - a.get_action(), - clap::ArgAction::Set | clap::ArgAction::Append - ) - }) - }) +/// Displays output associated with a failed parse of the shell's command-line +/// arguments. +/// +/// Rendering is delegated to `bpaf`, which colorizes messages using its bright +/// palette when the terminal supports it (respecting environment variables +/// such as `NO_COLOR`). If colorized output was disabled via `--disable-color`, +/// then falls back to monochrome rendering instead. +/// +/// # Arguments +/// +/// * `args` - The raw command-line arguments, including the program name. +/// * `failure` - The parse failure to report. +fn display_parse_failure(args: &[String], failure: bpaf::ParseFailure) { + if !args.iter().any(|arg| arg == "--disable-color") { + failure.print_message(PARSE_FAILURE_MAX_WIDTH); + } else if matches!( + &failure, + bpaf::ParseFailure::Stdout(..) | bpaf::ParseFailure::Completion(..) + ) { + print!("{}", failure.unwrap_stdout()); + } else { + eprintln!("{}", failure.unwrap_stderr()); } } @@ -144,29 +80,25 @@ pub fn run() { // // Parse args. // - let mut args: Vec<_> = std::env::args().collect(); - - // Work around clap's limitations handling +O options. - for arg in &mut args { - if arg.starts_with("+O") { - arg.insert_str(0, "--"); - } - } + let args: Vec<_> = std::env::args().collect(); let parsed_args = match CommandLineArgs::try_parse_from(args.iter().cloned()) { Ok(parsed_args) => parsed_args, - Err(e) => { - let _ = e.print(); - - // Check for whether this is something we'd truly consider fatal. clap returns - // errors for `--help`, `--version`, etc. - let exit_code = match e.kind() { - clap::error::ErrorKind::DisplayVersion => 0, - clap::error::ErrorKind::DisplayHelp => 0, - _ => 2, - }; - - std::process::exit(exit_code); + Err(failure) => { + // Help and version requests go to stdout with a successful exit + // code; everything else goes to stderr with an invalid usage code. + let is_success_output = matches!( + failure, + bpaf::ParseFailure::Stdout(..) | bpaf::ParseFailure::Completion(..) + ); + + display_parse_failure(&args, failure); + + if is_success_output { + std::process::exit(0); + } else { + std::process::exit(2); + } } }; @@ -485,7 +417,8 @@ async fn instantiate_shell_from_args( cli_args: &[String], ) -> Result { // Compute login flag. - let login = args.login || cli_args.first().is_some_and(|argv0| argv0.starts_with('-')); + let login = + args.login.is_some() || cli_args.first().is_some_and(|argv0| argv0.starts_with('-')); // Compute shell name. let shell_name = if args.command.is_some() && !args.script_args.is_empty() { @@ -568,7 +501,7 @@ async fn instantiate_shell_from_args( .treat_unset_variables_as_error(args.treat_unset_variables_as_error) .exit_on_nonzero_command_exit(args.exit_on_nonzero_command_exit) .disable_pathname_expansion(args.disable_pathname_expansion) - .verbose(args.verbose) + .verbose(args.verbose.is_some()) .parser(parser_impl) .error_formatter(new_error_behavior(args)) .shell_version(env!("CARGO_PKG_VERSION").to_string()); @@ -668,152 +601,3 @@ fn try_reset_terminal_to_defaults() -> Result<(), std::io::Error> { Ok(()) } - -#[cfg(test)] -#[allow(clippy::panic_in_result_fn)] -mod tests { - use super::*; - use anyhow::Result; - use pretty_assertions::{assert_eq, assert_matches}; - - fn args(strs: &[&str]) -> Vec { - strs.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn parse_empty_args() -> Result<()> { - let parsed_args = CommandLineArgs::try_parse_from(args(&["brush"]))?; - assert_matches!(parsed_args.script_args.as_slice(), []); - Ok(()) - } - - #[test] - fn parse_script_and_args() -> Result<()> { - let parsed_args = CommandLineArgs::try_parse_from(args(&[ - "brush", - "some-script", - "-x", - "1", - "--option", - ]))?; - assert_eq!( - parsed_args.script_args, - ["some-script", "-x", "1", "--option"] - ); - Ok(()) - } - - #[test] - fn parse_script_and_args_with_double_dash_in_script_args() -> Result<()> { - let parsed_args = CommandLineArgs::try_parse_from(args(&["brush", "some-script", "--"]))?; - assert_eq!(parsed_args.script_args, ["some-script", "--"]); - Ok(()) - } - - #[test] - fn parse_unknown_args() { - let result = CommandLineArgs::try_parse_from(args(&["brush", "--unknown-option"])); - assert!(result.is_err()); - } - - #[test] - fn parse_c_with_double_dash_separator() -> Result<()> { - let parsed_args = - CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "echo hello", "arg0"]))?; - assert_eq!(parsed_args.command, Some("echo hello".to_string())); - assert_eq!(parsed_args.script_args, ["arg0"]); - Ok(()) - } - - #[test] - fn parse_c_with_double_dash_no_command() { - assert!(CommandLineArgs::try_parse_from(args(&["brush", "-c", "--"])).is_err()); - } - - #[test] - fn parse_c_with_double_dash_command_is_double_dash() -> Result<()> { - let parsed_args = - CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "--", "echo", "hi"]))?; - assert_eq!(parsed_args.command, Some("--".to_string())); - assert_eq!(parsed_args.script_args, ["echo", "hi"]); - Ok(()) - } - - #[test] - fn parse_ec_with_double_dash_separator() -> Result<()> { - let parsed_args = - CommandLineArgs::try_parse_from(args(&["brush", "-ec", "--", "echo hello", "arg0"]))?; - assert_eq!(parsed_args.command, Some("echo hello".to_string())); - assert!(parsed_args.exit_on_nonzero_command_exit); - assert_eq!(parsed_args.script_args, ["arg0"]); - Ok(()) - } - - #[test] - fn parse_c_with_value_before_double_dash_unchanged() -> Result<()> { - let parsed_args = - CommandLineArgs::try_parse_from(args(&["brush", "-c", "echo hi", "--", "arg0"]))?; - assert_eq!(parsed_args.command, Some("echo hi".to_string())); - assert_eq!(parsed_args.script_args, ["--", "arg0"]); - Ok(()) - } - - #[test] - fn parse_o_with_double_dash_is_not_transformed() { - // Unlike -c, bash's -o consumes -- as its literal value (invalid option - // name), not as an option terminator. Verify we don't transform it. - let result = CommandLineArgs::try_parse_from(args(&["brush", "-o", "--"])); - // Here, try_parse_from / try_parse_known splits at --, so -o ends up - // without a value and parsing correctly fails. The key assertion is - // that we MUST NOT reinterpret -- as an option terminator for -o and - // then take any later argument as its value. - assert!(result.is_err()); - } - - #[test] - fn parse_oc_not_treated_as_pending_c() -> Result<()> { - // -oc means -o with value "c", not -o flag + -c flag. The -- - // should NOT be treated as an option terminator for -c. - let parsed_args = CommandLineArgs::try_parse_from(args(&["brush", "-oc", "--", "echo"]))?; - // -o consumed "c" as its value; -- split the rest; no -c command. - assert!(parsed_args.command.is_none()); - assert_eq!(parsed_args.script_args, ["--", "echo"]); - Ok(()) - } - - #[test] - fn parse_bool_flag_before_double_dash_not_transformed() -> Result<()> { - // -e is a boolean flag, not -c. The -- should NOT be removed; - // everything from -- onward becomes positional (including -c). - let parsed_args = - CommandLineArgs::try_parse_from(args(&["brush", "-e", "--", "-c", "echo"]))?; - assert!(parsed_args.command.is_none()); - assert!(parsed_args.exit_on_nonzero_command_exit); - assert_eq!(parsed_args.script_args, ["--", "-c", "echo"]); - Ok(()) - } - - #[test] - fn parse_c_with_double_dash_and_later_double_dash() -> Result<()> { - // After removing the first --, -c gets "echo". The second -- is - // handled by try_parse_known and appears in script_args. - let parsed_args = - CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "echo", "--", "more"]))?; - assert_eq!(parsed_args.command, Some("echo".to_string())); - assert_eq!(parsed_args.script_args, ["--", "more"]); - Ok(()) - } - - #[test] - fn has_pending_c_flag_edge_cases() { - // Direct tests for the detection function. - assert!(CommandLineArgs::has_pending_c_flag("-c")); - assert!(CommandLineArgs::has_pending_c_flag("-ec")); - assert!(!CommandLineArgs::has_pending_c_flag("-C")); // uppercase, different flag - assert!(!CommandLineArgs::has_pending_c_flag("-oc")); // -o takes a value - assert!(!CommandLineArgs::has_pending_c_flag("--c")); // long-option-like - assert!(!CommandLineArgs::has_pending_c_flag("-")); // bare dash - assert!(!CommandLineArgs::has_pending_c_flag("c")); // no leading dash - assert!(!CommandLineArgs::has_pending_c_flag("")); // empty - } -} diff --git a/brush-shell/src/events.rs b/brush-shell/src/events.rs index 72f3d8f55..16c76a4aa 100644 --- a/brush-shell/src/events.rs +++ b/brush-shell/src/events.rs @@ -8,43 +8,79 @@ use tracing_subscriber::{ }; /// Type of event to trace. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum TraceEvent { /// Traces parsing and evaluation of arithmetic expressions. - #[clap(name = "arithmetic")] Arithmetic, /// Traces command execution. - #[clap(name = "commands")] Commands, /// Traces command completion generation. - #[clap(name = "complete")] Complete, /// Traces word expansion. - #[clap(name = "expand")] Expand, /// Traces functions. - #[clap(name = "functions")] Functions, /// Traces input controls. - #[clap(name = "input")] Input, /// Traces job management. - #[clap(name = "jobs")] Jobs, /// Traces the process of parsing tokens into an abstract syntax tree. - #[clap(name = "parse")] Parse, /// Traces pattern matching. - #[clap(name = "pattern")] Pattern, /// Traces the process of tokenizing input text. - #[clap(name = "tokenize")] Tokenize, /// Traces usage of unimplemented functionality. - #[clap(name = "unimplemented", alias = "unimp")] Unimplemented, } +impl TraceEvent { + /// Parses a trace event name (as accepted by `--debug`). + #[must_use] + pub fn parse(s: &str) -> Option { + Some(match s { + "arithmetic" => Self::Arithmetic, + "commands" => Self::Commands, + "complete" => Self::Complete, + "expand" => Self::Expand, + "functions" => Self::Functions, + "input" => Self::Input, + "jobs" => Self::Jobs, + "parse" => Self::Parse, + "pattern" => Self::Pattern, + "tokenize" => Self::Tokenize, + "unimplemented" | "unimp" => Self::Unimplemented, + _ => return None, + }) + } + + /// Returns all trace event names, in declaration order. + #[must_use] + pub const fn names() -> &'static [&'static str] { + &[ + "arithmetic", + "commands", + "complete", + "expand", + "functions", + "input", + "jobs", + "parse", + "pattern", + "tokenize", + "unimplemented", + ] + } +} + +impl std::str::FromStr for TraceEvent { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse(s).ok_or_else(|| format!("invalid trace event: `{s}`")) + } +} + impl Display for TraceEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/brush-shell/tests/compat_tests.rs b/brush-shell/tests/compat_tests.rs index 58380e840..89070236c 100644 --- a/brush-shell/tests/compat_tests.rs +++ b/brush-shell/tests/compat_tests.rs @@ -9,7 +9,6 @@ use anyhow::Result; use brush_test_harness::{ OracleConfig, RunnerConfig, ShellConfig, TestMode, TestOptions, TestRunner, WhichShell, }; -use clap::Parser; use std::path::{Path, PathBuf}; const BASH_CONFIG_NAME: &str = "bash"; diff --git a/brush-shell/tests/integration_tests.rs b/brush-shell/tests/integration_tests.rs index f2de5c460..2749a4c76 100644 --- a/brush-shell/tests/integration_tests.rs +++ b/brush-shell/tests/integration_tests.rs @@ -7,7 +7,6 @@ use anyhow::Result; use brush_test_harness::{RunnerConfig, TestMode, TestOptions, TestRunner}; -use clap::Parser; use std::path::{Path, PathBuf}; async fn run_brush_tests(mut options: TestOptions) -> Result { diff --git a/brush-test-harness/Cargo.toml b/brush-test-harness/Cargo.toml index 8d2c0ae1b..f44e08329 100644 --- a/brush-test-harness/Cargo.toml +++ b/brush-test-harness/Cargo.toml @@ -26,7 +26,7 @@ insta = ["dep:insta"] anyhow = "1.0.102" assert_cmd = "2.2.0" assert_fs = "1.1.3" -clap = { version = "4.6.0", features = ["derive", "env"] } +bpaf = { version = "0.9.27", features = ["derive", "bright-color"] } colored = "3.1.1" descape = "3.0.0" diff = "0.1.13" diff --git a/brush-test-harness/src/config.rs b/brush-test-harness/src/config.rs index d5a4a6dbb..7895d0e64 100644 --- a/brush-test-harness/src/config.rs +++ b/brush-test-harness/src/config.rs @@ -1,8 +1,13 @@ //! Configuration types for the test harness. -use clap::Parser; +use bpaf::Parser; +use std::str::FromStr; use std::{collections::HashSet, ffi::OsString, path::PathBuf}; +/// Maximum width used when rendering help and usage messages; mirrors the +/// default width used by `bpaf`. +const MAX_MESSAGE_WIDTH: usize = 100; + /// Which shell to use for a test. #[derive(Clone, Debug)] pub enum WhichShell { @@ -171,7 +176,7 @@ impl RunnerConfig { } /// Output format for test results. -#[derive(Clone, Copy, Default, clap::ValueEnum, Debug)] +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] pub enum OutputFormat { /// Human-readable colored output. #[default] @@ -182,100 +187,83 @@ pub enum OutputFormat { Terse, } +impl FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "pretty" => Ok(Self::Pretty), + "junit" => Ok(Self::Junit), + "terse" => Ok(Self::Terse), + _ => Err(format!("invalid output format: `{s}`")), + } + } +} + /// Command-line options for the test harness. -#[derive(Clone, Parser, Debug)] -#[clap(version, about, disable_help_flag = true, disable_version_flag = true)] +#[derive(Clone, Debug)] pub struct TestOptions { - /// Display usage information. - #[clap(long = "help", action = clap::ArgAction::HelpLong)] - pub help: Option, - /// Output format for test results. - #[clap(long = "format", default_value = "pretty")] pub format: OutputFormat, /// Display full details on known failures. - #[clap(long = "known-failure-details")] pub display_known_failure_details: bool, /// Display details regarding successful test cases. - #[clap(short = 'v', long = "verbose", env = "BRUSH_VERBOSE")] pub verbose: bool, /// Enable a specific configuration. - #[clap(long = "enable-config")] pub enabled_configs: Vec, /// List available tests without running them. - #[clap(long = "list")] pub list_tests_only: bool, /// Exactly match filters (not just substring match). - #[clap(long = "exact")] pub exact_match: bool, /// Optionally specify a non-default path for bash. - #[clap(long = "bash-path", default_value = "bash", env = "BASH_PATH")] pub bash_path: PathBuf, /// Optionally specify a non-default path for brush. - #[clap(long = "brush-path", default_value = "", env = "BRUSH_PATH")] pub brush_path: String, /// Optionally specify additional arguments for brush. - #[clap(long = "brush-args", default_value = "", env = "BRUSH_ARGS")] pub brush_args: String, /// Optionally specify a launcher command to prepend when invoking brush /// (e.g., "wasmtime run --" to execute a wasm build under wasmtime). - /// The string is split on whitespace; the first token becomes the program - /// to execute and the remainder are passed as leading arguments before - /// the brush binary path. - #[clap(long = "brush-launcher", default_value = "", env = "BRUSH_LAUNCHER")] pub brush_launcher: String, /// Runtime platform tags (e.g., "wasi", "wasm") describing the - /// environment in which brush is being executed. Test cases that - /// declare any of these tags in `incompatible_platforms` will be - /// skipped. May be specified multiple times on the CLI or as a - /// space-separated value in the environment variable. - #[clap( - long = "brush-platform-tags", - value_delimiter = ' ', - env = "BRUSH_PLATFORM_TAGS" - )] + /// environment in which brush is being executed. pub brush_platform_tags: Vec, /// Optionally specify path to test cases. - #[clap(long = "test-cases-path", env = "BRUSH_TEST_CASES")] pub test_cases_path: Option, /// Optionally specify PATH variable to use in shells. - #[clap(long = "test-path-var", env = "BRUSH_TEST_PATH_VAR")] pub test_path_var: Option, /// Show output from test cases (for compatibility only, has no effect). - #[clap(long = "show-output")] + #[allow(dead_code, reason = "accepted for compatibility only")] pub show_output: bool, /// Capture output? (for compatibility only, has no effect). - #[clap(long = "nocapture")] + #[allow(dead_code, reason = "accepted for compatibility only")] pub no_capture: bool, /// Colorize output? (for compatibility only, has no effect). - #[clap(long = "color", default_value_t = clap::ColorChoice::Auto)] - pub color: clap::ColorChoice, + #[allow(dead_code, reason = "accepted for compatibility only")] + pub color: Option, /// Run skipped tests only. - #[clap(long = "ignored")] pub skipped_tests_only: bool, /// Unstable flags (for compatibility only, has no effect). - #[clap(short = 'Z')] + #[allow(dead_code, reason = "accepted for compatibility only")] pub unstable_flag: Vec, /// Patterns for tests to be excluded. - #[clap(long = "skip")] pub exclude_filters: Vec, /// Patterns for tests to be included. @@ -283,6 +271,143 @@ pub struct TestOptions { } impl TestOptions { + /// Returns a parser for the test harness options. + #[must_use] + pub fn parser() -> impl Parser { + let format = bpaf::long("format") + .help("Output format for test results.") + .argument::("FORMAT") + .fallback(OutputFormat::Pretty); + let display_known_failure_details = bpaf::long("known-failure-details").switch(); + let verbose = bpaf::short('v') + .long("verbose") + .help("Display details regarding successful test cases.") + .env("BRUSH_VERBOSE") + .switch(); + let enabled_configs = bpaf::long("enable-config") + .argument::("CONFIG") + .many() + .fallback(Vec::new()); + let list_tests_only = bpaf::long("list").switch(); + let exact_match = bpaf::long("exact").switch(); + + let bash_path = bpaf::long("bash-path") + .help("Optionally specify a non-default path for bash.") + .env("BASH_PATH") + .argument::("PATH") + .fallback(PathBuf::from("bash")); + + let brush_path = bpaf::long("brush-path") + .help("Optionally specify a non-default path for brush.") + .env("BRUSH_PATH") + .argument::("PATH") + .fallback(String::new()); + let brush_args = bpaf::long("brush-args") + .help("Additional arguments for brush.") + .env("BRUSH_ARGS") + .argument::("ARGS") + .fallback(String::new()); + let brush_launcher = bpaf::long("brush-launcher") + .help("Launcher command to prepend when invoking brush.") + .env("BRUSH_LAUNCHER") + .argument::("CMD") + .fallback(String::new()); + // N.B. The environment value is space-separated, matching clap's + // former `value_delimiter`. `.some()` (not `.many()`) is required so + // that absence on the CLI lets the environment fallback apply. + let brush_platform_tags = bpaf::long("brush-platform-tags") + .help("Runtime platform tags describing the execution environment; test cases declaring any of these in `incompatible_platforms` will be skipped.") + .env("BRUSH_PLATFORM_TAGS") + .argument::("TAGS") + .some("TAGS") + .parse(|tags: Vec| { + Ok::, String>( + tags.iter() + .flat_map(|t| t.split_whitespace().map(str::to_owned)) + .collect(), + ) + }) + .fallback(Vec::new()); + let test_cases_path = bpaf::long("test-cases-path") + .help("Optionally specify path to test cases.") + .env("BRUSH_TEST_CASES") + .argument::("PATH") + .optional(); + let test_path_var = bpaf::long("test-path-var") + .help("Optionally specify PATH variable to use in shells.") + .env("BRUSH_TEST_PATH_VAR") + .argument::("VAR") + .optional(); + let show_output = bpaf::long("show-output").switch(); + let no_capture = bpaf::long("nocapture").switch(); + let color = bpaf::long("color").argument::("WHEN").optional(); + let skipped_tests_only = bpaf::long("ignored").switch(); + let unstable_flag = bpaf::short('Z') + .argument::("FLAG") + .many() + .fallback(Vec::new()); + let exclude_filters = bpaf::long("skip") + .argument::("PATTERN") + .many() + .fallback(Vec::new()); + let include_filters = bpaf::positional::("FILTERS") + .many() + .fallback(Vec::new()); + + bpaf::construct!(TestOptions { + format, + display_known_failure_details, + verbose, + enabled_configs, + list_tests_only, + exact_match, + bash_path, + brush_path, + brush_args, + brush_launcher, + brush_platform_tags, + test_cases_path, + test_path_var, + show_output, + no_capture, + color, + skipped_tests_only, + unstable_flag, + exclude_filters, + include_filters, + }) + } + + /// Parses the test harness options from the given arguments. + /// + /// # Arguments + /// + /// * `args` - The arguments, including the program name. + /// + /// # Panics + /// + /// Panics on invalid usage, printing the relevant message first. Help and + /// version requests print to stdout and exit successfully. + pub fn parse_from>(args: impl IntoIterator) -> Self { + let args: Vec = args.into_iter().map(|s| s.as_ref().to_string()).collect(); + // N.B. The first argument is the program name. + let rest = args.get(1..).unwrap_or(&[]).to_vec(); + + match Self::parser().to_options().run_inner(rest.as_slice()) { + Ok(options) => options, + Err(failure @ bpaf::ParseFailure::Stdout(..)) => { + // N.B. Rendering is delegated to bpaf so messages get colorized + // when the terminal supports it. + failure.print_message(MAX_MESSAGE_WIDTH); + std::process::exit(0); + } + Err(failure) => { + failure.print_message(MAX_MESSAGE_WIDTH); + std::process::exit(2); + } + } + } + /// Returns the configured platform tags as a set. pub fn platform_tags(&self) -> HashSet { self.brush_platform_tags.iter().cloned().collect() diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index ef035aa0f..967bcad41 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -27,9 +27,6 @@ brush-shell = { version = "^0.4.0", path = "../brush-shell", features = [ "schema", ] } clap = { version = "4.6.0", features = ["derive"] } -clap_complete = "4.6.4" -clap_mangen = "0.3.0" -clap-markdown = "0.1.5" schemars = "1.2.1" serde_json = "1.0.149" xshell = "0.2.7" diff --git a/xtask/src/generate.rs b/xtask/src/generate.rs index dd4ca33c6..9012bd84d 100644 --- a/xtask/src/generate.rs +++ b/xtask/src/generate.rs @@ -1,7 +1,8 @@ //! Generation commands for documentation, completions, and schemas. //! //! This module provides commands for generating various artifacts: -//! - **Documentation**: Man pages and markdown help text from clap definitions +//! - **Documentation**: Man pages and markdown help text from the shell's +//! command-line parser (bpaf) //! - **Completions**: Shell completion scripts for bash, zsh, fish, etc. //! - **Schemas**: JSON schemas for configuration files //! - **Distribution archives**: Reproducible documentation bundles with checksums @@ -9,7 +10,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -use clap::{CommandFactory, Parser}; +use clap::Parser; use xshell::{Shell, cmd}; /// Generate various artifacts. @@ -38,7 +39,7 @@ pub enum DocsCommand { } /// Completion script generation commands. -#[derive(Parser)] +#[derive(Clone, Copy, Debug, Parser)] pub enum CompletionCommand { /// Generate completion script for `bash`. Bash, @@ -107,24 +108,25 @@ pub fn run(cmd: &GenCommand, verbose: bool) -> Result<()> { DocsCommand::Markdown(args) => gen_markdown_docs(args, verbose), DocsCommand::Dist(args) => gen_docs_dist(args, verbose), }, - GenCommand::Completion(completion_cmd) => { - let shell = match completion_cmd { - CompletionCommand::Bash => clap_complete::Shell::Bash, - CompletionCommand::Elvish => clap_complete::Shell::Elvish, - CompletionCommand::Fish => clap_complete::Shell::Fish, - CompletionCommand::PowerShell => clap_complete::Shell::PowerShell, - CompletionCommand::Zsh => clap_complete::Shell::Zsh, - }; - gen_completion_script(shell, verbose); - Ok(()) - } + GenCommand::Completion(completion_cmd) => gen_completion_script(*completion_cmd, verbose), GenCommand::Schema(schema_cmd) => match schema_cmd { SchemaCommand::Config(args) => gen_config_schema(args, verbose), }, } } +/// Renders the shell's help content using its bpaf-based parser. +fn render_help_text() -> Result { + let parser = brush_shell::args::CommandLineArgs::option_parser(); + match parser.run_inner(&["--help"][..]) { + Err(failure) => Ok(failure.unwrap_stdout()), + Ok(_) => anyhow::bail!("unexpectedly parsed --help"), + } +} + fn gen_man(args: &GenerateManArgs, verbose: bool) -> Result<()> { + use std::fmt::Write as _; + if verbose { eprintln!("Generating man pages to: {}", args.output_dir.display()); } @@ -135,9 +137,25 @@ fn gen_man(args: &GenerateManArgs, verbose: bool) -> Result<()> { std::fs::create_dir_all(&args.output_dir)?; } - // Generate! - let cmd = brush_shell::args::CommandLineArgs::command(); - clap_mangen::generate_to(cmd, &args.output_dir)?; + // Generate a simple roff-formatted man page from the rendered help text. + let help = render_help_text()?; + let mut man = String::new(); + writeln!(&mut man, ".TH BRUSH 1 \"brush\" \"\" \"User Commands\"")?; + writeln!(&mut man, ".SH NAME")?; + writeln!(&mut man, "brush \\- Bo[u]rn[e] RUsty SHell")?; + writeln!(&mut man, ".SH SYNOPSIS")?; + writeln!(&mut man, ".nf")?; + writeln!(&mut man, "{}", help.lines().next().unwrap_or_default())?; + writeln!(&mut man, ".fi")?; + writeln!(&mut man, ".SH OPTIONS")?; + writeln!(&mut man, ".nf")?; + for line in help.lines().skip(1) { + writeln!(&mut man, "{line}")?; + } + writeln!(&mut man, ".fi")?; + + let output_path = args.output_dir.join("brush.1"); + std::fs::write(output_path, man)?; Ok(()) } @@ -150,13 +168,13 @@ fn gen_markdown_docs(args: &GenerateMarkdownArgs, verbose: bool) -> Result<()> { ); } - let options = clap_markdown::MarkdownOptions::new() - .show_footer(false) - .show_table_of_contents(true); + // Generate markdown from the bpaf-rendered help text. + let help = render_help_text()?; + let markdown = format!("# brush\n\n```text\n{help}\n```\n"); - // Generate! - let markdown = - clap_markdown::help_markdown_custom::(&options); + if let Some(parent) = args.output_path.parent() { + std::fs::create_dir_all(parent)?; + } std::fs::write(&args.output_path, markdown)?; Ok(()) @@ -164,14 +182,37 @@ fn gen_markdown_docs(args: &GenerateMarkdownArgs, verbose: bool) -> Result<()> { /// Generate a shell completion script to stdout. /// -/// The completion script is written directly to stdout so it can be piped -/// to a file or sourced directly by the shell. -fn gen_completion_script(shell: clap_complete::Shell, verbose: bool) { +/// N.B. Completions are generated at runtime by the shell binary itself (via +/// bpaf), so this shells out to a locally built binary. +fn gen_completion_script(shell: CompletionCommand, verbose: bool) -> Result<()> { if verbose { - eprintln!("Generating {shell} completion script..."); + eprintln!("Generating {shell:?} completion script..."); } - let mut cmd = brush_shell::args::CommandLineArgs::command(); - clap_complete::generate(shell, &mut cmd, "brush", &mut std::io::stdout()); + + let style_flag = match shell { + CompletionCommand::Bash => "--bpaf-complete-style-bash", + CompletionCommand::Elvish => "--bpaf-complete-style-elvish", + CompletionCommand::Fish => "--bpaf-complete-style-fish", + CompletionCommand::PowerShell => { + anyhow::bail!("PowerShell completions are not supported by bpaf") + } + CompletionCommand::Zsh => "--bpaf-complete-style-zsh", + }; + + // Locate or build the brush binary. + let sh = Shell::new()?; + let workspace_root = std::env::current_dir()?; + let binary = workspace_root.join("target/debug/brush"); + if !binary.exists() { + cmd!(sh, "cargo build -p brush-shell").run()?; + } + + let script = cmd!(sh, "{binary} {style_flag}") + .read() + .context("Failed to generate completion script")?; + print!("{script}"); + + Ok(()) } fn gen_config_schema(args: &GenerateSchemaArgs, verbose: bool) -> Result<()> {