diff --git a/.gitignore b/.gitignore index 7ade8af04..c764cd21b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ target/ mutants.out*/ target-thin/ +target-usage/ +target-clap/ diff --git a/Cargo.lock b/Cargo.lock index 0a65764b3..bacb83d90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,6 @@ name = "brush-builtins" version = "0.2.0" dependencies = [ "anyhow", - "bpaf", "brush-core", "brush-parser", "cfg-if", @@ -442,6 +441,7 @@ dependencies = [ "cfg-if", "check_elevation", "chrono", + "clap", "color-print", "command-fds", "fancy-regex", @@ -464,6 +464,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tracing", + "usage-rs", "uuid", "uzers", "whoami", @@ -4589,6 +4590,33 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "usage-argv" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832f849be4b8046c843219bc92d067a6689530df53f56e3c0a52ff5ebbd2f89b" + +[[package]] +name = "usage-derive" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5634bb965ae075c5cedfc27942f7e6110bf5e7e169cba04de86346f4aeee41e7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "usage-rs" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a8c2b63902582429527f21cd91d2da0065fb9222b8a2a5b87d8f77c7daf71e" +dependencies = [ + "usage-argv", + "usage-derive", +] + [[package]] name = "utf16_iter" version = "1.0.5" diff --git a/brush-builtins/Cargo.toml b/brush-builtins/Cargo.toml index 795c7cf23..c79466ba6 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" -bpaf = { version = "0.9.27", features = ["derive"] } + 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 383fdc0f3..4232b5247 100644 --- a/brush-builtins/src/alias.rs +++ b/brush-builtins/src/alias.rs @@ -1,25 +1,44 @@ -use bpaf::Bpaf; use std::io::Write; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; /// Manage aliases within the shell. -#[derive(Bpaf)] pub(crate) struct AliasCommand { - /// Print all defined aliases in a reusable format. - #[bpaf(short('p'))] print: bool, - - /// List of aliases to display or update. - #[bpaf(positional("name[=value]"))] aliases: Vec, } -impl builtins::Command for AliasCommand { +const ID_PRINT: &str = "print"; +const ID_ALIASES: &str = "aliases"; + +impl builtins::SpecCommand for AliasCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - alias_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ArgSpec::flag( + ID_PRINT, + &['p'], + &[], + "Print all defined aliases in a reusable format.", + )], + positionals: &[PositionalSpec::many(ID_ALIASES, "name[=value]")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + print: values.flag(ID_PRINT), + aliases: values.positional_values(ID_ALIASES).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/bg.rs b/brush-builtins/src/bg.rs index 93ea3017a..9b89bb31e 100644 --- a/brush-builtins/src/bg.rs +++ b/brush-builtins/src/bg.rs @@ -1,22 +1,32 @@ -use bpaf::Bpaf; - use std::io::Write; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ExecutionResult, argmodel::PositionalSpec, builtins}; /// Moves a job to run in the background. -#[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 { +const ID_JOB_SPECS: &str = "job_specs"; + +impl builtins::SpecCommand for BgCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - bg_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::many(ID_JOB_SPECS, "JOB_SPECS")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + job_specs: values.positional_values(ID_JOB_SPECS).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/bind.rs b/brush-builtins/src/bind.rs index 3966b4dc4..01514cd45 100644 --- a/brush-builtins/src/bind.rs +++ b/brush-builtins/src/bind.rs @@ -1,11 +1,12 @@ -use bpaf::Parser; use itertools::Itertools as _; use std::{collections::HashMap, io::Write, str::FromStr, sync::Arc}; use strum::IntoEnumIterator; use tokio::sync::Mutex; use brush_core::{ - ExecutionExitCode, ExecutionResult, builtins, + ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, CommandSpec, PositionalSpec}, + builtins, interfaces::{self, InputFunction, KeyAction, KeySequence}, sys, trace_categories, }; @@ -49,6 +50,22 @@ impl BindKeyMap { } } +const ID_KEYMAP: &str = "keymap"; +const ID_LIST_FUNCS: &str = "list_funcs"; +const ID_LIST_FUNCS_AND_BINDINGS: &str = "list_funcs_and_bindings"; +const ID_LIST_FUNCS_AND_BINDINGS_REUSABLE: &str = "list_funcs_and_bindings_reusable"; +const ID_LIST_KEY_SEQS_MACROS: &str = "list_key_seqs_that_invoke_macros"; +const ID_LIST_KEY_SEQS_MACROS_REUSABLE: &str = "list_key_seqs_that_invoke_macros_reusable"; +const ID_LIST_VARS: &str = "list_vars"; +const ID_LIST_VARS_REUSABLE: &str = "list_vars_reusable"; +const ID_QUERY_FUNC_BINDINGS: &str = "query_func_bindings"; +const ID_REMOVE_FUNC_BINDINGS: &str = "remove_func_bindings"; +const ID_REMOVE_KEY_SEQ_BINDING: &str = "remove_key_seq_binding"; +const ID_BINDINGS_FILE: &str = "bindings_file"; +const ID_KEY_SEQ_BINDINGS: &str = "key_seq_bindings"; +const ID_LIST_KEY_SEQ_BINDINGS: &str = "list_key_seq_bindings"; +const ID_KEY_SEQUENCE: &str = "key_sequence"; + /// Inspect and modify key bindings and other input configuration. pub(crate) struct BindCommand { keymap: Option, @@ -68,74 +85,125 @@ pub(crate) struct BindCommand { key_sequence: Option, } -impl builtins::Command for BindCommand { +impl builtins::SpecCommand for BindCommand { type Error = 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 { + fn spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::value(ID_KEYMAP, &['m'], &[], "KEYMAP", "Name of key map to use."), + ArgSpec::flag(ID_LIST_FUNCS, &['l'], &[], "List functions."), + ArgSpec::flag( + ID_LIST_FUNCS_AND_BINDINGS, + &['P'], + &[], + "List functions and bindings.", + ), + ArgSpec::flag( + ID_LIST_FUNCS_AND_BINDINGS_REUSABLE, + &['p'], + &[], + "List functions and bindings in a format suitable for use as input.", + ), + ArgSpec::flag( + ID_LIST_KEY_SEQS_MACROS, + &['S'], + &[], + "List key sequences that invoke macros.", + ), + ArgSpec::flag( + ID_LIST_KEY_SEQS_MACROS_REUSABLE, + &['s'], + &[], + "List key sequences that invoke macros in a format suitable for use as input.", + ), + ArgSpec::flag(ID_LIST_VARS, &['V'], &[], "List variables."), + ArgSpec::flag( + ID_LIST_VARS_REUSABLE, + &['v'], + &[], + "List variables in a format suitable for use as input.", + ), + ArgSpec::value( + ID_QUERY_FUNC_BINDINGS, + &['q'], + &[], + "FUNC_NAME", + "Find the keys bound to the given named function.", + ), + ArgSpec::value( + ID_REMOVE_FUNC_BINDINGS, + &['u'], + &[], + "FUNC_NAME", + "Remove all bindings for the given named function.", + ), + ArgSpec::value( + ID_REMOVE_KEY_SEQ_BINDING, + &['r'], + &[], + "KEY_SEQ", + "Remove the binding for the given key sequence.", + ), + ArgSpec::value( + ID_BINDINGS_FILE, + &['f'], + &[], + "PATH", + "Import bindings from the given file.", + ), + ArgSpec::value( + ID_KEY_SEQ_BINDINGS, + &['x'], + &[], + "BINDING", + "Bind key sequence to command.", + ), + ArgSpec::flag( + ID_LIST_KEY_SEQ_BINDINGS, + &['X'], + &[], + "List key sequence bindings.", + ), + ], + positionals: &[PositionalSpec::one(ID_KEY_SEQUENCE, "KEY_SEQUENCE")], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let keymap = + match values.value(ID_KEYMAP) { + Some(s) => Some(BindKeyMap::from_str(s).map_err(|message| { + builtins::BuiltinArgParseError { + message, + help_request: false, + } + })?), + None => None, + }; + + Ok(Self { 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, + list_funcs: values.flag(ID_LIST_FUNCS), + list_funcs_and_bindings: values.flag(ID_LIST_FUNCS_AND_BINDINGS), + list_funcs_and_bindings_reusable: values.flag(ID_LIST_FUNCS_AND_BINDINGS_REUSABLE), + list_key_seqs_that_invoke_macros: values.flag(ID_LIST_KEY_SEQS_MACROS), + list_key_seqs_that_invoke_macros_reusable: values + .flag(ID_LIST_KEY_SEQS_MACROS_REUSABLE), + list_vars: values.flag(ID_LIST_VARS), + list_vars_reusable: values.flag(ID_LIST_VARS_REUSABLE), + query_func_bindings: values.value(ID_QUERY_FUNC_BINDINGS).map(str::to_owned), + remove_func_bindings: values.value(ID_REMOVE_FUNC_BINDINGS).map(str::to_owned), + remove_key_seq_binding: values.value(ID_REMOVE_KEY_SEQ_BINDING).map(str::to_owned), + bindings_file: values.value(ID_BINDINGS_FILE).map(str::to_owned), + key_seq_bindings: values.values(ID_KEY_SEQ_BINDINGS).to_vec(), + list_key_seq_bindings: values.flag(ID_LIST_KEY_SEQ_BINDINGS), + key_sequence: values + .value_of_positional(ID_KEY_SEQUENCE) + .map(str::to_owned), }) } diff --git a/brush-builtins/src/break_.rs b/brush-builtins/src/break_.rs index b5748ec5b..d0dc50785 100644 --- a/brush-builtins/src/break_.rs +++ b/brush-builtins/src/break_.rs @@ -1,20 +1,38 @@ -use bpaf::Parser; - -use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; +use brush_core::{ + ExecutionControlFlow, ExecutionExitCode, ExecutionResult, argmodel::PositionalSpec, builtins, +}; /// Breaks out of a control-flow loop. pub(crate) struct BreakCommand { which_loop: i8, } -impl builtins::Command for BreakCommand { +const ID_WHICH_LOOP: &str = "which_loop"; + +impl builtins::SpecCommand 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 spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::one(ID_WHICH_LOOP, "WHICH_LOOP")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let which_loop = match values.value_of_positional(ID_WHICH_LOOP) { + Some(value) => value.parse().map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid numeric value: {value}"), + help_request: false, + })?, + None => 1, + }; + + Ok(Self { which_loop }) } fn about() -> &'static str { diff --git a/brush-builtins/src/builtin_.rs b/brush-builtins/src/builtin_.rs index a06977800..a99a3752a 100644 --- a/brush-builtins/src/builtin_.rs +++ b/brush-builtins/src/builtin_.rs @@ -1,3 +1,4 @@ +use brush_core::argmodel::{CommandSpec, ParsedValues}; use brush_core::{ExecutionResult, builtins}; /// Directly invokes a built-in, without going through typical search order. @@ -6,20 +7,27 @@ pub(crate) struct BuiltinCommand { args: Vec, } -impl builtins::DeclarationCommand for BuiltinCommand { - fn set_declarations(&mut self, args: Vec) { - self.args = args; - } -} +static BUILTIN_SPEC: CommandSpec = CommandSpec::EMPTY; -impl builtins::Command for BuiltinCommand { +impl builtins::SpecCommand for BuiltinCommand { type Error = brush_core::Error; - // N.B. Arguments are passed directly via `set_declarations`; the parser is + // N.B. Arguments are passed directly via `set_declarations`; spec() is // used only for help rendering. - fn parser() -> impl bpaf::Parser { - let args = bpaf::pure(Vec::new()); - bpaf::construct!(BuiltinCommand { args }) + fn spec() -> &'static CommandSpec { + &BUILTIN_SPEC + } + + fn from_matches(_values: &mut ParsedValues) -> Result { + Ok(Self::default()) + } + + fn uses_declarations() -> bool { + true + } + + fn set_declarations(&mut self, args: Vec) { + self.args = args; } fn about() -> &'static str { diff --git a/brush-builtins/src/caller.rs b/brush-builtins/src/caller.rs index bcf2b5a7f..3f69a9926 100644 --- a/brush-builtins/src/caller.rs +++ b/brush-builtins/src/caller.rs @@ -1,21 +1,38 @@ -use bpaf::Bpaf; - -use brush_core::{ExecutionResult, builtins, callstack}; use std::io::Write; +use brush_core::{ExecutionResult, argmodel::PositionalSpec, builtins, callstack}; + /// Return the context of the current subroutine call. -#[derive(Bpaf)] pub(crate) struct CallerCommand { - /// The number of call frames to go back. - #[bpaf(positional("EXPR"))] expr: Option, } -impl builtins::Command for CallerCommand { +const ID_EXPR: &str = "expr"; + +impl builtins::SpecCommand for CallerCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - caller_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::one(ID_EXPR, "EXPR")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let expr = match values.value_of_positional(ID_EXPR) { + Some(value) => Some(value.parse().map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid numeric value: {value}"), + help_request: false, + })?), + None => None, + }; + + Ok(Self { expr }) } fn about() -> &'static str { diff --git a/brush-builtins/src/cd.rs b/brush-builtins/src/cd.rs index 85826caaa..3dc677d8d 100644 --- a/brush-builtins/src/cd.rs +++ b/brush-builtins/src/cd.rs @@ -1,9 +1,11 @@ use std::io::Write; use std::path::PathBuf; -use bpaf::Parser; - -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, CommandSpec, PositionalSpec}, + builtins, error, +}; /// Change the current shell working directory. pub(crate) struct CdCommand { @@ -22,38 +24,61 @@ pub(crate) struct CdCommand { target_dir: Option, } -impl builtins::Command for CdCommand { +const ID_EXIT_ON_FAILED_CWD_RESOLUTION: &str = "exit_on_failed_cwd_resolution"; +const ID_FILE_WITH_XATTR_AS_DIR: &str = "file_with_xattr_as_dir"; +const ID_PHYSICAL: &str = "physical"; +const ID_LOGICAL: &str = "logical"; +const ID_TARGET_DIR: &str = "target_dir"; + +impl builtins::SpecCommand 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, + fn spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + ID_EXIT_ON_FAILED_CWD_RESOLUTION, + &['e'], + &[], + "Exit with non zero exit status if current working directory resolution fails.", + ), + ArgSpec::flag( + ID_FILE_WITH_XATTR_AS_DIR, + &['@'], + &[], + "Show file with extended attributes as a dir with extended attributes.", + ), + ArgSpec::flag( + ID_PHYSICAL, + &['P'], + &[], + "Use physical dir structure without following symlinks.", + ), + ArgSpec::flag(ID_LOGICAL, &['L'], &[], "Force following symlinks."), + ], + positionals: &[PositionalSpec::one(ID_TARGET_DIR, "TARGET_DIR")], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + // N.B. When both are supplied, physical wins; this preserves the old + // parser's alternation order (`-P` listed before `-L`). + let mode = if values.flag(ID_PHYSICAL) { + Some(true) + } else if values.flag(ID_LOGICAL) { + Some(false) + } else { + None + }; + + Ok(Self { + exit_on_failed_cwd_resolution: values.flag(ID_EXIT_ON_FAILED_CWD_RESOLUTION), + file_with_xattr_as_dir: values.flag(ID_FILE_WITH_XATTR_AS_DIR), mode, - target_dir, + target_dir: values.value_of_positional(ID_TARGET_DIR).map(PathBuf::from), }) } diff --git a/brush-builtins/src/command.rs b/brush-builtins/src/command.rs index a1b6216a5..6510958d1 100644 --- a/brush-builtins/src/command.rs +++ b/brush-builtins/src/command.rs @@ -1,7 +1,9 @@ use std::{fmt::Display, io::Write, path::Path}; use brush_core::{ - ExecutionResult, builtins, commands, pathsearch, + ExecutionResult, + argmodel::{ArgSpec, CommandSpec}, + builtins, commands, pathsearch, sys::{self, fs::PathExt}, }; @@ -32,26 +34,43 @@ impl CommandCommand { } } -impl builtins::Command for CommandCommand { +const ID_USE_DEFAULT_PATH: &str = "use_default_path"; +const ID_PRINT_DESCRIPTION: &str = "print_description"; +const ID_PRINT_VERBOSE_DESCRIPTION: &str = "print_verbose_description"; + +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag(ID_USE_DEFAULT_PATH, &['p'], &[], "Use default PATH value."), + ArgSpec::flag( + ID_PRINT_DESCRIPTION, + &['v'], + &[], + "Display a short description of the command.", + ), + ArgSpec::flag( + ID_PRINT_VERBOSE_DESCRIPTION, + &['V'], + &[], + "Display a more verbose description of the command.", + ), + ], + positionals: &[], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + use_default_path: values.flag(ID_USE_DEFAULT_PATH), + print_description: values.flag(ID_PRINT_DESCRIPTION), + print_verbose_description: values.flag(ID_PRINT_VERBOSE_DESCRIPTION), + command_and_args: values.trailing().to_vec(), }) } @@ -67,10 +86,6 @@ impl builtins::Command for CommandCommand { true } - fn set_trailing_args(&mut self, args: Vec) { - self.command_and_args = args; - } - async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/complete.rs b/brush-builtins/src/complete.rs index e93b529a4..98b2af313 100644 --- a/brush-builtins/src/complete.rs +++ b/brush-builtins/src/complete.rs @@ -1,12 +1,34 @@ -use bpaf::Parser; use std::collections::HashMap; -use std::ffi::OsStr; use std::fmt::Write as _; use std::io::Write; +use std::str::FromStr; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues, PositionalSpec}; use brush_core::completion::{self, CompleteAction, CompleteOption, Spec}; use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape}; +const ID_OPTIONS: &str = "options"; +const ID_ACTIONS: &str = "actions"; +const ID_GLOB_PATTERN: &str = "glob_pattern"; +const ID_WORD_LIST: &str = "word_list"; +const ID_FUNCTION_NAME: &str = "function_name"; +const ID_COMMAND: &str = "command"; +const ID_FILTER_PATTERN: &str = "filter_pattern"; +const ID_PREFIX: &str = "prefix"; +const ID_SUFFIX: &str = "suffix"; +const ID_ACTION_ALIAS: &str = "action_alias"; +const ID_ACTION_BUILTIN: &str = "action_builtin"; +const ID_ACTION_COMMAND: &str = "action_command"; +const ID_ACTION_DIRECTORY: &str = "action_directory"; +const ID_ACTION_EXPORTED: &str = "action_exported"; +const ID_ACTION_FILE: &str = "action_file"; +const ID_ACTION_GROUP: &str = "action_group"; +const ID_ACTION_JOB: &str = "action_job"; +const ID_ACTION_KEYWORD: &str = "action_keyword"; +const ID_ACTION_SERVICE: &str = "action_service"; +const ID_ACTION_USER: &str = "action_user"; +const ID_ACTION_VARIABLE: &str = "action_variable"; + struct CommonCompleteCommandArgs { options: Vec, actions: Vec, @@ -32,97 +54,39 @@ struct CommonCompleteCommandArgs { } 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 { + fn from_matches(values: &ParsedValues) -> Result { + let mut options = Vec::new(); + for value in values.values(ID_OPTIONS) { + options.push(value.parse().map_err(|_| invalid_value("-o", value))?); + } + + let mut actions = Vec::new(); + for value in values.values(ID_ACTIONS) { + actions.push(value.parse().map_err(|_| invalid_value("-A", value))?); + } + + Ok(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, + glob_pattern: values.value(ID_GLOB_PATTERN).map(str::to_owned), + word_list: values.value(ID_WORD_LIST).map(str::to_owned), + function_name: values.value(ID_FUNCTION_NAME).map(str::to_owned), + command: values.value(ID_COMMAND).map(str::to_owned), + filter_pattern: values.value(ID_FILTER_PATTERN).map(str::to_owned), + prefix: values.value(ID_PREFIX).map(str::to_owned), + suffix: values.value(ID_SUFFIX).map(str::to_owned), + action_alias: values.flag(ID_ACTION_ALIAS), + action_builtin: values.flag(ID_ACTION_BUILTIN), + action_command: values.flag(ID_ACTION_COMMAND), + action_directory: values.flag(ID_ACTION_DIRECTORY), + action_exported: values.flag(ID_ACTION_EXPORTED), + action_file: values.flag(ID_ACTION_FILE), + action_group: values.flag(ID_ACTION_GROUP), + action_job: values.flag(ID_ACTION_JOB), + action_keyword: values.flag(ID_ACTION_KEYWORD), + action_service: values.flag(ID_ACTION_SERVICE), + action_user: values.flag(ID_ACTION_USER), + action_variable: values.flag(ID_ACTION_VARIABLE), }) } @@ -230,32 +194,25 @@ fn join_flag_looking_values(args: Vec) -> Vec { 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) +/// Returns a rendered parse error for an invalid option/action value. +fn invalid_value(option: &str, value: &str) -> builtins::BuiltinArgParseError { + builtins::BuiltinArgParseError { + message: format!("invalid value for {option}: `{value}`"), + help_request: false, + } } -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, - }, - } +/// Parses the given command against the provided arguments, pre-joining +/// flag-looking values onto their value-taking options; see +/// [`join_flag_looking_values`]. +fn parse_joined( + args: Vec, +) -> Result { + let joined = join_flag_looking_values(args); + + let mut values = brush_core::builtins::argmodel::backend().parse(T::spec(), "", &joined)?; + + T::from_matches(&mut values) } /// Configure programmable command completion. @@ -269,10 +226,161 @@ pub(crate) struct CompleteCommand { names: Vec, } -impl builtins::Command for CompleteCommand { +static COMPLETE_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::value( + ID_OPTIONS, + &['o'], + &[], + "OPT", + "Options governing the behavior of completions.", + ), + ArgSpec::value( + ID_ACTIONS, + &['A'], + &[], + "ACTION", + "Actions to apply to generate completions.", + ), + ArgSpec::value( + ID_GLOB_PATTERN, + &['G'], + &[], + "GLOB", + "File glob pattern to be expanded to generate completions.", + ), + ArgSpec::value( + ID_WORD_LIST, + &['W'], + &[], + "WORD_LIST", + "List of words that will be considered as completions.", + ), + ArgSpec::value( + ID_FUNCTION_NAME, + &['F'], + &[], + "FUNC_NAME", + "Name of a shell function to invoke to generate completions.", + ), + ArgSpec::value( + ID_COMMAND, + &['C'], + &[], + "COMMAND", + "Command to execute to generate completions.", + ), + ArgSpec::value( + ID_FILTER_PATTERN, + &['X'], + &[], + "PATTERN", + "Pattern used as filter for completions.", + ), + ArgSpec::value( + ID_PREFIX, + &['P'], + &[], + "PREFIX", + "Prefix pattern used as filter for completions.", + ), + ArgSpec::value( + ID_SUFFIX, + &['S'], + &[], + "SUFFIX", + "Suffix pattern used as filter for completions.", + ), + ArgSpec::flag(ID_ACTION_ALIAS, &['a'], &[], "Complete with valid aliases."), + ArgSpec::flag( + ID_ACTION_BUILTIN, + &['b'], + &[], + "Complete with names of shell builtins.", + ), + ArgSpec::flag( + ID_ACTION_COMMAND, + &['c'], + &[], + "Complete with names of executable commands.", + ), + ArgSpec::flag( + ID_ACTION_DIRECTORY, + &['d'], + &[], + "Complete with directory names.", + ), + ArgSpec::flag( + ID_ACTION_EXPORTED, + &['e'], + &[], + "Complete with names of exported shell variables.", + ), + ArgSpec::flag(ID_ACTION_FILE, &['f'], &[], "Complete with filenames."), + ArgSpec::flag( + ID_ACTION_GROUP, + &['g'], + &[], + "Complete with valid user groups.", + ), + ArgSpec::flag(ID_ACTION_JOB, &['j'], &[], "Complete with job specs."), + ArgSpec::flag(ID_ACTION_KEYWORD, &['k'], &[], "Complete with keywords."), + ArgSpec::flag( + ID_ACTION_SERVICE, + &['s'], + &[], + "Complete with names of system services.", + ), + ArgSpec::flag( + ID_ACTION_USER, + &['u'], + &[], + "Complete with valid usernames.", + ), + ArgSpec::flag( + ID_ACTION_VARIABLE, + &['v'], + &[], + "Complete with names of shell variables.", + ), + ArgSpec::flag( + "print", + &['p'], + &[], + "Display registered completion settings.", + ), + ArgSpec::flag( + "remove", + &['r'], + &[], + "Remove the completion settings associated with the given command.", + ), + ArgSpec::flag( + "use_as_default", + &['D'], + &[], + "Apply these settings to the default completion scenario.", + ), + ArgSpec::flag( + "use_for_empty_line", + &['E'], + &[], + "Apply these settings to completion of empty lines.", + ), + ArgSpec::flag( + "use_for_initial_word", + &['I'], + &[], + "Apply these settings to completion of the initial word of the input line.", + ), + ], + positionals: &[PositionalSpec::many("names", "NAMES")], +}; + +impl builtins::SpecCommand for CompleteCommand { type Error = brush_core::Error; - /// Overrides the default [`builtins::Command::new`] flow to pre-join + /// Overrides the default [`builtins::SpecCommand::new`] flow to pre-join /// flag-looking values onto their value-taking options; see /// [`join_flag_looking_values`]. fn new(args: I) -> Result @@ -281,38 +389,22 @@ impl builtins::Command for CompleteCommand { { // 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)) + parse_joined(args) + } + + fn spec() -> &'static CommandSpec { + &COMPLETE_SPEC } - 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 from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + print: values.flag("print"), + remove: values.flag("remove"), + use_as_default: values.flag("use_as_default"), + use_for_empty_line: values.flag("use_for_empty_line"), + use_for_initial_word: values.flag("use_for_initial_word"), + common_args: CommonCompleteCommandArgs::from_matches(values)?, + names: values.positional_values("names").to_vec(), }) } @@ -599,10 +691,131 @@ pub(crate) struct CompGenCommand { word: Option, } -impl builtins::Command for CompGenCommand { +static COMPGEN_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::value( + ID_OPTIONS, + &['o'], + &[], + "OPT", + "Options governing the behavior of completions.", + ), + ArgSpec::value( + ID_ACTIONS, + &['A'], + &[], + "ACTION", + "Actions to apply to generate completions.", + ), + ArgSpec::value( + ID_GLOB_PATTERN, + &['G'], + &[], + "GLOB", + "File glob pattern to be expanded to generate completions.", + ), + ArgSpec::value( + ID_WORD_LIST, + &['W'], + &[], + "WORD_LIST", + "List of words that will be considered as completions.", + ), + ArgSpec::value( + ID_FUNCTION_NAME, + &['F'], + &[], + "FUNC_NAME", + "Name of a shell function to invoke to generate completions.", + ), + ArgSpec::value( + ID_COMMAND, + &['C'], + &[], + "COMMAND", + "Command to execute to generate completions.", + ), + ArgSpec::value( + ID_FILTER_PATTERN, + &['X'], + &[], + "PATTERN", + "Pattern used as filter for completions.", + ), + ArgSpec::value( + ID_PREFIX, + &['P'], + &[], + "PREFIX", + "Prefix pattern used as filter for completions.", + ), + ArgSpec::value( + ID_SUFFIX, + &['S'], + &[], + "SUFFIX", + "Suffix pattern used as filter for completions.", + ), + ArgSpec::flag(ID_ACTION_ALIAS, &['a'], &[], "Complete with valid aliases."), + ArgSpec::flag( + ID_ACTION_BUILTIN, + &['b'], + &[], + "Complete with names of shell builtins.", + ), + ArgSpec::flag( + ID_ACTION_COMMAND, + &['c'], + &[], + "Complete with names of executable commands.", + ), + ArgSpec::flag( + ID_ACTION_DIRECTORY, + &['d'], + &[], + "Complete with directory names.", + ), + ArgSpec::flag( + ID_ACTION_EXPORTED, + &['e'], + &[], + "Complete with names of exported shell variables.", + ), + ArgSpec::flag(ID_ACTION_FILE, &['f'], &[], "Complete with filenames."), + ArgSpec::flag( + ID_ACTION_GROUP, + &['g'], + &[], + "Complete with valid user groups.", + ), + ArgSpec::flag(ID_ACTION_JOB, &['j'], &[], "Complete with job specs."), + ArgSpec::flag(ID_ACTION_KEYWORD, &['k'], &[], "Complete with keywords."), + ArgSpec::flag( + ID_ACTION_SERVICE, + &['s'], + &[], + "Complete with names of system services.", + ), + ArgSpec::flag( + ID_ACTION_USER, + &['u'], + &[], + "Complete with valid usernames.", + ), + ArgSpec::flag( + ID_ACTION_VARIABLE, + &['v'], + &[], + "Complete with names of shell variables.", + ), + ], + positionals: &[PositionalSpec::one("word", "WORD")], +}; + +impl builtins::SpecCommand for CompGenCommand { type Error = brush_core::Error; - /// Overrides the default [`builtins::Command::new`] flow to pre-join + /// Overrides the default [`builtins::SpecCommand::new`] flow to pre-join /// flag-looking values onto their value-taking options; see /// [`join_flag_looking_values`]. fn new(args: I) -> Result @@ -611,14 +824,20 @@ impl builtins::Command for CompGenCommand { { // 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)) + parse_joined(args) + } + + fn spec() -> &'static CommandSpec { + &COMPGEN_SPEC } - fn parser() -> impl bpaf::Parser { - let common_args = CommonCompleteCommandArgs::parser(); - let word = bpaf::positional::("WORD").optional(); + fn from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + common_args: CommonCompleteCommandArgs::from_matches(values)?, - bpaf::construct!(CompGenCommand { common_args, word }) + // N.B. The word can only start with a hyphen if it's after a --. + word: values.value_of_positional("word").map(str::to_owned), + }) } fn about() -> &'static str { @@ -692,47 +911,100 @@ pub(crate) struct CompOptCommand { names: Vec, } -impl builtins::Command for CompOptCommand { +static COMPOPT_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + "update_default", + &['D'], + &[], + "Update the default completion settings.", + ), + ArgSpec::flag( + "update_empty", + &['E'], + &[], + "Update the completion settings for empty lines.", + ), + ArgSpec::flag( + "update_initial_word", + &['I'], + &[], + "Update the completion settings for the initial word of the input line.", + ), + ArgSpec::value( + ID_OPTIONS, + &['o'], + &[], + "OPT", + "Enable the specified option for selected completion scenarios.", + ), + // N.B. Declared for help rendering; `+o` occurrences are extracted + // from the token stream before the backend parses. + ArgSpec::hidden_value("disabled_options", &[], &["+o"], "OPT", ""), + ], + positionals: &[PositionalSpec::many("names", "NAMES")], +}; + +impl builtins::SpecCommand 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()) - }; + /// Overrides the default [`builtins::SpecCommand::new`] flow to pre-join + /// flag-looking values onto their value-taking options (see + /// [`join_flag_looking_values`]) and to extract `+o` occurrences, whose + /// optional values the backend's required-value options cannot express. + 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 joined = join_flag_looking_values(args); + + let mut disabled_options = Vec::new(); + let mut remaining = Vec::with_capacity(joined.len()); + let mut iter = joined.into_iter().peekable(); + + while let Some(arg) = iter.next() { + if arg == "+o" { + // Consume a following word as the option value when it parses; + // otherwise the occurrence enables no specific option. + if let Some(next) = iter.peek() + && let Ok(option) = CompleteOption::from_str(next.as_str()) + { + iter.next(); + disabled_options.push(option); + } + } else { + remaining.push(arg); + } + } + + let mut values = + brush_core::builtins::argmodel::backend().parse(Self::spec(), "", &remaining)?; + + let mut command = Self::from_matches(&mut values)?; + command.disabled_options = disabled_options; + + Ok(command) + } + + fn spec() -> &'static CommandSpec { + &COMPOPT_SPEC + } - let names = bpaf::positional::("NAMES") - .help("If specified, scopes updates to completions of the named commands.") - .many(); + fn from_matches(values: &mut ParsedValues) -> Result { + let mut enabled_options = Vec::new(); + for value in values.values(ID_OPTIONS) { + enabled_options.push(value.parse().map_err(|_| invalid_value("-o", value))?); + } - bpaf::construct!(CompOptCommand { - update_default, - update_empty, - update_initial_word, + Ok(Self { + update_default: values.flag("update_default"), + update_empty: values.flag("update_empty"), + update_initial_word: values.flag("update_initial_word"), enabled_options, - disabled_options, - names, + disabled_options: Vec::new(), + names: values.positional_values("names").to_vec(), }) } diff --git a/brush-builtins/src/continue_.rs b/brush-builtins/src/continue_.rs index 6701b2985..d2cd4cc9d 100644 --- a/brush-builtins/src/continue_.rs +++ b/brush-builtins/src/continue_.rs @@ -1,20 +1,38 @@ -use bpaf::Parser; - -use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; +use brush_core::{ + ExecutionControlFlow, ExecutionExitCode, ExecutionResult, argmodel::PositionalSpec, builtins, +}; /// Continue to the next iteration of a control-flow loop. pub(crate) struct ContinueCommand { which_loop: i8, } -impl builtins::Command for ContinueCommand { +const ID_WHICH_LOOP: &str = "which_loop"; + +impl builtins::SpecCommand 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 spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::one(ID_WHICH_LOOP, "WHICH_LOOP")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let which_loop = match values.value_of_positional(ID_WHICH_LOOP) { + Some(value) => value.parse().map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid numeric value: {value}"), + help_request: false, + })?, + None => 1, + }; + + Ok(Self { which_loop }) } fn about() -> &'static str { diff --git a/brush-builtins/src/declare.rs b/brush-builtins/src/declare.rs index 917316530..668213888 100644 --- a/brush-builtins/src/declare.rs +++ b/brush-builtins/src/declare.rs @@ -1,6 +1,7 @@ use itertools::Itertools; use std::{io::Write, sync::LazyLock}; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues}; use brush_core::{ ErrorKind, ExecutionResult, builtins, env::{self, EnvironmentLookup, EnvironmentScope}, @@ -12,6 +13,12 @@ use brush_core::{ }, }; +const ID_FUNCTION_NAMES_OR_DEFS_ONLY: &str = "function_names_or_defs_only"; +const ID_FUNCTION_NAMES_ONLY: &str = "function_names_only"; +const ID_CREATE_GLOBAL: &str = "create_global"; +const ID_LOCALS_INHERIT_FROM_PREV_SCOPE: &str = "locals_inherit_from_prev_scope"; +const ID_PRINT: &str = "print"; + /// Display or update variables and their attributes. pub(crate) struct DeclareCommand { function_names_or_defs_only: bool, @@ -33,84 +40,238 @@ pub(crate) struct DeclareCommand { make_exported: Option, // N.B. These are skipped during parsing, but filled in by the - // DeclarationCommand trait. + // SpecCommand trait. declarations: Vec, } -impl builtins::Command for DeclareCommand { +/// Expands groups of `+`-style options (e.g., `+ax`) into individual hidden +/// long spellings (e.g., `--+a --+x`) that the argument backend can match +/// against the disable-side arguments in this command's spec. +fn expand_plus_options(args: Vec) -> Vec { + args.into_iter() + .flat_map(|arg| { + if let Some(group) = arg.strip_prefix('+').filter(|g| !g.is_empty()) { + if group.starts_with('+') || group.contains('=') { + // Not an option group (e.g., `++x` or `+foo=bar`); + // pass it through unchanged. + vec![arg] + } else { + group.chars().map(|c| format!("--+{c}")).collect::>() + } + } else { + vec![arg] + } + }) + .collect() +} + +static DECLARE_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + ID_FUNCTION_NAMES_OR_DEFS_ONLY, + &['f'], + &[], + "Constrain to function names or definitions.", + ), + ArgSpec::flag( + ID_FUNCTION_NAMES_ONLY, + &['F'], + &[], + "Constrain to function names only.", + ), + ArgSpec::flag( + ID_CREATE_GLOBAL, + &['g'], + &[], + "Create global variable, if applicable.", + ), + ArgSpec::flag( + ID_LOCALS_INHERIT_FROM_PREV_SCOPE, + &['I'], + &[], + "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.", + ), + ArgSpec::flag( + ID_PRINT, + &['p'], + &[], + "Display each item's attributes and values.", + ), + ArgSpec::flag( + "make_indexed_array_enable", + &['a'], + &[], + "Make the variable an indexed array.", + ), + ArgSpec::hidden_flag("make_indexed_array_disable", &[], &["+a"], ""), + ArgSpec::flag( + "make_associative_array_enable", + &['A'], + &[], + "Make the variable an associative array.", + ), + ArgSpec::hidden_flag("make_associative_array_disable", &[], &["+A"], ""), + ArgSpec::flag( + "capitalize_value_on_assignment_enable", + &['c'], + &[], + "Enable capitalize-on-assignment for the variable.", + ), + ArgSpec::hidden_flag("capitalize_value_on_assignment_disable", &[], &["+c"], ""), + ArgSpec::flag( + "make_integer_enable", + &['i'], + &[], + "Mark the variable as integer-typed", + ), + ArgSpec::hidden_flag("make_integer_disable", &[], &["+i"], ""), + ArgSpec::flag( + "lowercase_value_on_assignment_enable", + &['l'], + &[], + "Enable lowercase-on-assignment for the variable.", + ), + ArgSpec::hidden_flag("lowercase_value_on_assignment_disable", &[], &["+l"], ""), + ArgSpec::flag( + "make_nameref_enable", + &['n'], + &[], + "Mark the variable as a name reference", + ), + ArgSpec::hidden_flag("make_nameref_disable", &[], &["+n"], ""), + ArgSpec::flag( + "make_readonly_enable", + &['r'], + &[], + "Mark the variable as read-only.", + ), + ArgSpec::hidden_flag("make_readonly_disable", &[], &["+r"], ""), + ArgSpec::flag( + "make_traced_enable", + &['t'], + &[], + "Enable tracing for the variable.", + ), + ArgSpec::hidden_flag("make_traced_disable", &[], &["+t"], ""), + ArgSpec::flag( + "uppercase_value_on_assignment_enable", + &['u'], + &[], + "Enable uppercase-on-assignment for the variable.", + ), + ArgSpec::hidden_flag("uppercase_value_on_assignment_disable", &[], &["+u"], ""), + ArgSpec::flag( + "make_exported_enable", + &['x'], + &[], + "Mark the variable for export.", + ), + ArgSpec::hidden_flag("make_exported_disable", &[], &["+x"], ""), + ], + positionals: &[], +}; + +impl builtins::SpecCommand for DeclareCommand { + type Error = brush_core::Error; + fn takes_plus_options() -> bool { true } - type Error = brush_core::Error; + fn uses_declarations() -> bool { + true + } - 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 set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } + + fn spec() -> &'static CommandSpec { + &DECLARE_SPEC + } + + /// Overrides the default [`builtins::SpecCommand::new`] flow so that + /// `+`-style option spellings are rewritten into forms the argument + /// backend can match; see [`expand_plus_options`]. + 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 expanded = expand_plus_options(args); + + let mut values = builtins::argmodel::backend().parse(Self::spec(), "", &expanded)?; + + Self::from_matches(&mut values) + } + + fn from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + function_names_or_defs_only: values.flag(ID_FUNCTION_NAMES_OR_DEFS_ONLY), + function_names_only: values.flag(ID_FUNCTION_NAMES_ONLY), + create_global: values.flag(ID_CREATE_GLOBAL), + locals_inherit_from_prev_scope: values.flag(ID_LOCALS_INHERIT_FROM_PREV_SCOPE), + print: values.flag(ID_PRINT), + make_indexed_array: crate::read_plus_minus( + values, + "make_indexed_array_enable", + "make_indexed_array_disable", + ), + make_associative_array: crate::read_plus_minus( + values, + "make_associative_array_enable", + "make_associative_array_disable", + ), + capitalize_value_on_assignment: crate::read_plus_minus( + values, + "capitalize_value_on_assignment_enable", + "capitalize_value_on_assignment_disable", + ), + make_integer: crate::read_plus_minus( + values, + "make_integer_enable", + "make_integer_disable", + ), + lowercase_value_on_assignment: crate::read_plus_minus( + values, + "lowercase_value_on_assignment_enable", + "lowercase_value_on_assignment_disable", + ), + make_nameref: crate::read_plus_minus( + values, + "make_nameref_enable", + "make_nameref_disable", + ), + make_readonly: crate::read_plus_minus( + values, + "make_readonly_enable", + "make_readonly_disable", + ), + make_traced: crate::read_plus_minus( + values, + "make_traced_enable", + "make_traced_disable", + ), + uppercase_value_on_assignment: crate::read_plus_minus( + values, + "uppercase_value_on_assignment_enable", + "uppercase_value_on_assignment_disable", + ), + make_exported: crate::read_plus_minus( + values, + "make_exported_enable", + "make_exported_disable", + ), + + declarations: Vec::new(), }) } @@ -179,12 +340,6 @@ enum DeclareVerb { Readonly, } -impl builtins::DeclarationCommand for DeclareCommand { - fn set_declarations(&mut self, declarations: Vec) { - self.declarations = declarations; - } -} - impl DeclareCommand { fn try_display_declaration( &self, diff --git a/brush-builtins/src/dirs.rs b/brush-builtins/src/dirs.rs index b9f7ab3a4..7044c7d67 100644 --- a/brush-builtins/src/dirs.rs +++ b/brush-builtins/src/dirs.rs @@ -1,7 +1,6 @@ -use bpaf::Bpaf; use std::io::Write; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ExecutionResult, argmodel::ArgSpec, builtins}; #[derive(Debug, thiserror::Error)] pub(crate) enum DirError { @@ -26,32 +25,56 @@ impl From<&DirError> for brush_core::ExecutionExitCode { impl brush_core::BuiltinError for DirError {} /// Manage the current directory stack. -#[derive(Default, Bpaf)] +#[derive(Default)] pub(crate) struct DirsCommand { - /// Clear the directory stack. - #[bpaf(short('c'))] clear: bool, - - /// Don't tilde-shorten paths. - #[bpaf(short('l'))] tilde_long: bool, - - /// Print one directory per line instead of all on one line. - #[bpaf(short('p'))] print_one_per_line: bool, - - /// Print one directory per line with its index. - #[bpaf(short('v'))] print_one_per_line_with_index: bool, - // - // TODO(dirs): implement +N and -N } -impl builtins::Command for DirsCommand { +const ID_CLEAR: &str = "clear"; +const ID_TILDE_LONG: &str = "tilde_long"; +const ID_PRINT_ONE_PER_LINE: &str = "print_one_per_line"; +const ID_PRINT_ONE_PER_LINE_WITH_INDEX: &str = "print_one_per_line_with_index"; + +impl builtins::SpecCommand for DirsCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - dirs_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + // TODO(dirs): implement +N and -N + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag(ID_CLEAR, &['c'], &[], "Clear the directory stack."), + ArgSpec::flag(ID_TILDE_LONG, &['l'], &[], "Don't tilde-shorten paths."), + ArgSpec::flag( + ID_PRINT_ONE_PER_LINE, + &['p'], + &[], + "Print one directory per line instead of all on one line.", + ), + ArgSpec::flag( + ID_PRINT_ONE_PER_LINE_WITH_INDEX, + &['v'], + &[], + "Print one directory per line with its index.", + ), + ], + positionals: &[], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + clear: values.flag(ID_CLEAR), + tilde_long: values.flag(ID_TILDE_LONG), + print_one_per_line: values.flag(ID_PRINT_ONE_PER_LINE), + print_one_per_line_with_index: values.flag(ID_PRINT_ONE_PER_LINE_WITH_INDEX), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/dot.rs b/brush-builtins/src/dot.rs index ecc90423b..2d8cf0732 100644 --- a/brush-builtins/src/dot.rs +++ b/brush-builtins/src/dot.rs @@ -1,6 +1,6 @@ use std::path::Path; -use brush_core::{ExecutionExitCode, builtins}; +use brush_core::{ExecutionExitCode, argmodel::CommandSpec, builtins}; use std::io::Write; /// Evaluate the provided script in the current shell environment. @@ -12,18 +12,22 @@ pub(crate) struct DotCommand { script_args: Vec, } -impl builtins::Command for DotCommand { +impl builtins::SpecCommand 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()); + fn spec() -> &'static CommandSpec { + &CommandSpec::EMPTY + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let mut trailing = values.trailing().iter(); + let script_path = trailing.next().cloned().unwrap_or_default(); - bpaf::construct!(DotCommand { + Ok(Self { script_path, - script_args, + script_args: trailing.cloned().collect(), }) } @@ -39,14 +43,6 @@ impl builtins::Command for DotCommand { 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>, diff --git a/brush-builtins/src/echo.rs b/brush-builtins/src/echo.rs index e65cc6c95..de204113b 100644 --- a/brush-builtins/src/echo.rs +++ b/brush-builtins/src/echo.rs @@ -1,44 +1,57 @@ use std::io::Write; -use brush_core::{ExecutionResult, builtins, escape}; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues}; +use brush_core::{ExecutionResult, builtins}; /// Echo text to standard output. pub(crate) struct EchoCommand { - /// Suppress the trailing newline from the output. no_trailing_newline: bool, - - /// Interpret backslash escapes in the provided text. interpret_backslash_escapes: bool, - - /// Do not interpret backslash escapes in the provided text. no_interpret_backslash_escapes: bool, - - /// Tokens to echo to standard output. args: Vec, } -impl builtins::Command for EchoCommand { +const ID_NO_NEWLINE: &str = "no_trailing_newline"; +const ID_INTERPRET: &str = "interpret_backslash_escapes"; +const ID_NO_INTERPRET: &str = "no_interpret_backslash_escapes"; + +static ECHO_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + ID_NO_NEWLINE, + &['n'], + &[], + "Suppress the trailing newline from the output.", + ), + ArgSpec::flag( + ID_INTERPRET, + &['e'], + &[], + "Interpret backslash escapes in the provided text.", + ), + ArgSpec::flag( + ID_NO_INTERPRET, + &['E'], + &[], + "Do not interpret backslash escapes in the provided text.", + ), + ], + positionals: &[], +}; + +impl builtins::SpecCommand for EchoCommand { 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 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 spec() -> &'static CommandSpec { + &ECHO_SPEC + } + + fn from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + no_trailing_newline: values.flag(ID_NO_NEWLINE), + interpret_backslash_escapes: values.flag(ID_INTERPRET), + no_interpret_backslash_escapes: values.flag(ID_NO_INTERPRET), + args: values.trailing().to_vec(), }) } @@ -54,10 +67,6 @@ impl builtins::Command for EchoCommand { true } - fn set_trailing_args(&mut self, args: Vec) { - self.args = args; - } - async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, @@ -71,9 +80,9 @@ impl builtins::Command for EchoCommand { s.push(' '); } - let (expanded_arg, keep_going) = escape::expand_backslash_escapes( + let (expanded_arg, keep_going) = brush_core::escape::expand_backslash_escapes( arg.as_str(), - escape::EscapeExpansionMode::EchoBuiltin, + brush_core::escape::EscapeExpansionMode::EchoBuiltin, )?; s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice())); diff --git a/brush-builtins/src/enable.rs b/brush-builtins/src/enable.rs index 3d6fdb7ef..ba58e89f5 100644 --- a/brush-builtins/src/enable.rs +++ b/brush-builtins/src/enable.rs @@ -1,47 +1,94 @@ -use bpaf::Bpaf; use itertools::Itertools; use std::io::Write; -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, error, +}; /// Enable, disable, or display built-in commands. -#[derive(Bpaf)] pub(crate) struct EnableCommand { - /// Print a list of built-in commands. - #[bpaf(short('a'))] print_list: bool, - - /// Disables the specified built-in commands. - #[bpaf(short('n'))] disable: bool, - - /// Print a list of built-in commands with reusable output. - #[bpaf(short('p'))] #[expect(dead_code)] print_reusably: bool, - - /// Only operate on special built-in commands. - #[bpaf(short('s'))] special_only: bool, - - /// Path to a shared object from which built-in commands will be loaded. - #[bpaf(short('f'), argument("PATH"))] shared_object_path: Option, - - /// Remove the built-in commands loaded from the indicated object path. - #[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 { +const ID_PRINT_LIST: &str = "print_list"; +const ID_DISABLE: &str = "disable"; +const ID_PRINT_REUSABLY: &str = "print_reusably"; +const ID_SPECIAL_ONLY: &str = "special_only"; +const ID_SHARED_OBJECT_PATH: &str = "shared_object_path"; +const ID_REMOVE_LOADED_BUILTIN: &str = "remove_loaded_builtin"; +const ID_NAMES: &str = "names"; + +impl builtins::SpecCommand for EnableCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - enable_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag( + ID_PRINT_LIST, + &['a'], + &[], + "Print a list of built-in commands.", + ), + ArgSpec::flag( + ID_DISABLE, + &['n'], + &[], + "Disables the specified built-in commands.", + ), + ArgSpec::flag( + ID_PRINT_REUSABLY, + &['p'], + &[], + "Print a list of built-in commands with reusable output.", + ), + ArgSpec::flag( + ID_SPECIAL_ONLY, + &['s'], + &[], + "Only operate on special built-in commands.", + ), + ArgSpec::value( + ID_SHARED_OBJECT_PATH, + &['f'], + &[], + "PATH", + "Path to a shared object from which built-in commands will be loaded.", + ), + ArgSpec::flag( + ID_REMOVE_LOADED_BUILTIN, + &['d'], + &[], + "Remove the built-in commands loaded from the indicated object path.", + ), + ], + positionals: &[PositionalSpec::many(ID_NAMES, "NAMES")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + print_list: values.flag(ID_PRINT_LIST), + disable: values.flag(ID_DISABLE), + print_reusably: values.flag(ID_PRINT_REUSABLY), + special_only: values.flag(ID_SPECIAL_ONLY), + shared_object_path: values.value(ID_SHARED_OBJECT_PATH).map(ToOwned::to_owned), + remove_loaded_builtin: values.flag(ID_REMOVE_LOADED_BUILTIN), + names: values.positional_values(ID_NAMES).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/eval.rs b/brush-builtins/src/eval.rs index e306a8281..712d0f27e 100644 --- a/brush-builtins/src/eval.rs +++ b/brush-builtins/src/eval.rs @@ -1,4 +1,4 @@ -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ExecutionResult, argmodel::CommandSpec, builtins}; /// Evaluate the given string as script. pub(crate) struct EvalCommand { @@ -6,15 +6,19 @@ pub(crate) struct EvalCommand { args: Vec, } -impl builtins::Command for EvalCommand { +impl builtins::SpecCommand 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()); + fn spec() -> &'static CommandSpec { + &CommandSpec::EMPTY + } - bpaf::construct!(EvalCommand { args }) + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + args: values.trailing().to_vec(), + }) } fn about() -> &'static str { @@ -29,10 +33,6 @@ impl builtins::Command for EvalCommand { true } - 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/exec.rs b/brush-builtins/src/exec.rs index 08ed5d871..3364f2961 100644 --- a/brush-builtins/src/exec.rs +++ b/brush-builtins/src/exec.rs @@ -1,7 +1,10 @@ -use bpaf::Parser; use std::{borrow::Cow, os::unix::process::CommandExt}; -use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands}; +use brush_core::{ + ErrorKind, ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, CommandSpec}, + builtins, commands, +}; /// Exec the provided command. pub(crate) struct ExecCommand { @@ -18,29 +21,49 @@ pub(crate) struct ExecCommand { args: Vec, } -impl builtins::Command for ExecCommand { +const ID_NAME_FOR_ARGV0: &str = "name_for_argv0"; +const ID_EMPTY_ENVIRONMENT: &str = "empty_environment"; +const ID_EXEC_AS_LOGIN: &str = "exec_as_login"; + +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::value( + ID_NAME_FOR_ARGV0, + &['a'], + &[], + "NAME", + "Pass given name as zeroth argument to command.", + ), + ArgSpec::flag( + ID_EMPTY_ENVIRONMENT, + &['c'], + &[], + "Exec command with an empty environment.", + ), + ArgSpec::flag( + ID_EXEC_AS_LOGIN, + &['l'], + &[], + "Exec command as a login shell.", + ), + ], + positionals: &[], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + name_for_argv0: values.value(ID_NAME_FOR_ARGV0).map(str::to_string), + empty_environment: values.flag(ID_EMPTY_ENVIRONMENT), + exec_as_login: values.flag(ID_EXEC_AS_LOGIN), + args: values.trailing().to_vec(), }) } @@ -60,10 +83,6 @@ impl builtins::Command for ExecCommand { "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 ca5c44dfc..593f55d3a 100644 --- a/brush-builtins/src/exit.rs +++ b/brush-builtins/src/exit.rs @@ -1,22 +1,26 @@ -use std::io::Write; - use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; +use std::io::Write; /// Exit the shell. pub(crate) struct ExitCommand { - /// The exit code to return. code: Option, } -impl builtins::Command for ExitCommand { +impl builtins::SpecCommand 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); + fn spec() -> &'static builtins::argmodel::CommandSpec { + &builtins::argmodel::CommandSpec::EMPTY + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + // N.B. Only the leading options are parsed; the remaining tokens are + // captured verbatim via `takes_trailing_args`. + let code = values.trailing().first().cloned(); - bpaf::construct!(ExitCommand { code }) + Ok(Self { code }) } fn about() -> &'static str { @@ -31,14 +35,6 @@ impl builtins::Command for ExitCommand { 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>, diff --git a/brush-builtins/src/export.rs b/brush-builtins/src/export.rs index 98b8c7252..b72f52498 100644 --- a/brush-builtins/src/export.rs +++ b/brush-builtins/src/export.rs @@ -1,6 +1,7 @@ use itertools::Itertools; use std::io::Write; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues}; use brush_core::{ ExecutionExitCode, ExecutionResult, builtins, env::{EnvironmentLookup, EnvironmentScope}, @@ -8,6 +9,10 @@ use brush_core::{ variables, }; +const ID_NAMES_ARE_FUNCTIONS: &str = "names_are_functions"; +const ID_UNEXPORT: &str = "unexport"; +const ID_DISPLAY_EXPORTED_NAMES: &str = "display_exported_names"; + /// Add or update exported shell variables. pub(crate) struct ExportCommand { /// Names are treated as function names. @@ -24,39 +29,55 @@ pub(crate) struct ExportCommand { // Declarations // // N.B. These are skipped by the parser, but filled in by the - // BuiltinDeclarationCommand trait. + // SpecCommand trait. declarations: Vec, } -impl builtins::DeclarationCommand for ExportCommand { - fn set_declarations(&mut self, declarations: Vec) { - self.declarations = declarations; - } -} +static EXPORT_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + ID_NAMES_ARE_FUNCTIONS, + &['f'], + &[], + "Names are treated as function names.", + ), + ArgSpec::flag(ID_UNEXPORT, &['n'], &[], "Un-export the names."), + ArgSpec::flag( + ID_DISPLAY_EXPORTED_NAMES, + &['p'], + &[], + "Display all exported names.", + ), + ], + positionals: &[], +}; -impl builtins::Command for ExportCommand { +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + &EXPORT_SPEC + } + + fn from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + names_are_functions: values.flag(ID_NAMES_ARE_FUNCTIONS), + unexport: values.flag(ID_UNEXPORT), + display_exported_names: values.flag(ID_DISPLAY_EXPORTED_NAMES), + + // N.B. Declarations are captured separately from options. + declarations: Vec::new(), }) } + fn uses_declarations() -> bool { + true + } + + fn set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } + fn about() -> &'static str { "Add or update exported shell variables." } diff --git a/brush-builtins/src/factory.rs b/brush-builtins/src/factory.rs index 99eeb8e44..4f5d1ef7b 100644 --- a/brush-builtins/src/factory.rs +++ b/brush-builtins/src/factory.rs @@ -4,7 +4,9 @@ use std::collections::HashMap; use super::*; #[allow(unused_imports, reason = "not all builtins are used in all configs")] -use brush_core::builtins::{self, builtin, decl_builtin, raw_arg_builtin, simple_builtin}; +use brush_core::builtins::{ + self, builtin, decl_builtin, raw_arg_builtin, simple_builtin, spec_builtin, +}; /// Identifies well-known sets of builtins. #[derive(Clone, Copy, Eq, PartialEq)] @@ -36,7 +38,7 @@ pub fn default_builtins( #[cfg(feature = "builtin.break")] m.insert( "break".into(), - builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.colon")] m.insert( @@ -46,50 +48,65 @@ pub fn default_builtins( #[cfg(feature = "builtin.continue")] m.insert( "continue".into(), - builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.dot")] - m.insert(".".into(), builtin::().special()); + m.insert(".".into(), spec_builtin::().special()); #[cfg(feature = "builtin.eval")] - m.insert("eval".into(), builtin::().special()); + m.insert( + "eval".into(), + spec_builtin::().special(), + ); #[cfg(all(feature = "builtin.exec", unix))] - m.insert("exec".into(), builtin::().special()); + m.insert( + "exec".into(), + spec_builtin::().special(), + ); #[cfg(feature = "builtin.exit")] - m.insert("exit".into(), builtin::().special()); + m.insert( + "exit".into(), + spec_builtin::().special(), + ); #[cfg(feature = "builtin.export")] m.insert( "export".into(), - decl_builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.return")] m.insert( "return".into(), - builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.set")] - m.insert("set".into(), builtin::().special()); + m.insert( + "set".into(), + spec_builtin::().special(), + ); #[cfg(feature = "builtin.shift")] m.insert( "shift".into(), - builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.trap")] - m.insert("trap".into(), builtin::().special()); + m.insert( + "trap".into(), + spec_builtin::().special(), + ); #[cfg(feature = "builtin.unset")] m.insert( "unset".into(), - builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.declare")] m.insert( "readonly".into(), - decl_builtin::().special(), + spec_builtin::().special(), ); #[cfg(feature = "builtin.times")] m.insert( "times".into(), - builtin::().special(), + spec_builtin::().special(), ); // @@ -97,131 +114,161 @@ pub fn default_builtins( // #[cfg(feature = "builtin.alias")] - m.insert("alias".into(), builtin::()); // TODO(alias): should be exec_declaration_builtin + m.insert("alias".into(), spec_builtin::()); // TODO(alias): should be exec_declaration_builtin #[cfg(feature = "builtin.bg")] - m.insert("bg".into(), builtin::()); + m.insert("bg".into(), spec_builtin::()); #[cfg(feature = "builtin.cd")] - m.insert("cd".into(), builtin::()); + m.insert("cd".into(), spec_builtin::()); #[cfg(feature = "builtin.command")] - m.insert("command".into(), builtin::()); + m.insert( + "command".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.false")] m.insert("false".into(), simple_builtin::()); #[cfg(feature = "builtin.fg")] - m.insert("fg".into(), builtin::()); + m.insert("fg".into(), spec_builtin::()); #[cfg(feature = "builtin.getopts")] - m.insert("getopts".into(), builtin::()); + m.insert( + "getopts".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.hash")] - m.insert("hash".into(), builtin::()); + m.insert("hash".into(), spec_builtin::()); #[cfg(feature = "builtin.help")] - m.insert("help".into(), builtin::()); + m.insert("help".into(), spec_builtin::()); #[cfg(feature = "builtin.jobs")] - m.insert("jobs".into(), builtin::()); + m.insert("jobs".into(), spec_builtin::()); #[cfg(all(feature = "builtin.kill", unix))] - m.insert("kill".into(), builtin::()); + m.insert("kill".into(), spec_builtin::()); #[cfg(feature = "builtin.declare")] m.insert( "local".into(), - decl_builtin::(), + spec_builtin::(), ); #[cfg(feature = "builtin.pwd")] - m.insert("pwd".into(), builtin::()); + m.insert("pwd".into(), spec_builtin::()); #[cfg(feature = "builtin.read")] - m.insert("read".into(), builtin::()); + m.insert("read".into(), spec_builtin::()); #[cfg(feature = "builtin.true")] m.insert("true".into(), simple_builtin::()); #[cfg(feature = "builtin.type")] - m.insert("type".into(), builtin::()); + m.insert("type".into(), spec_builtin::()); #[cfg(all(feature = "builtin.ulimit", unix))] - m.insert("ulimit".into(), builtin::()); + m.insert("ulimit".into(), spec_builtin::()); #[cfg(all(feature = "builtin.umask", unix))] - m.insert("umask".into(), builtin::()); + m.insert("umask".into(), spec_builtin::()); #[cfg(feature = "builtin.unalias")] - m.insert("unalias".into(), builtin::()); + m.insert( + "unalias".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.wait")] - m.insert("wait".into(), builtin::()); + m.insert("wait".into(), spec_builtin::()); #[cfg(feature = "builtin.fc")] - m.insert("fc".into(), builtin::()); + m.insert("fc".into(), spec_builtin::()); if matches!(set, BuiltinSet::BashMode) { #[cfg(feature = "builtin.builtin")] m.insert( "builtin".into(), - raw_arg_builtin::(), + spec_builtin::(), ); #[cfg(feature = "builtin.declare")] m.insert( "declare".into(), - decl_builtin::(), + spec_builtin::(), ); #[cfg(feature = "builtin.echo")] - m.insert("echo".into(), builtin::()); + m.insert("echo".into(), spec_builtin::()); #[cfg(feature = "builtin.enable")] - m.insert("enable".into(), builtin::()); + m.insert("enable".into(), spec_builtin::()); #[cfg(feature = "builtin.let")] - m.insert("let".into(), builtin::()); + m.insert("let".into(), spec_builtin::()); #[cfg(feature = "builtin.mapfile")] - m.insert("mapfile".into(), builtin::()); + m.insert( + "mapfile".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.mapfile")] - m.insert("readarray".into(), builtin::()); + m.insert( + "readarray".into(), + spec_builtin::(), + ); #[cfg(all(feature = "builtin.printf", any(unix, windows)))] - m.insert("printf".into(), builtin::()); + m.insert("printf".into(), spec_builtin::()); #[cfg(feature = "builtin.shopt")] - m.insert("shopt".into(), builtin::()); + m.insert("shopt".into(), spec_builtin::()); #[cfg(feature = "builtin.dot")] - m.insert("source".into(), builtin::().special()); + m.insert( + "source".into(), + spec_builtin::().special(), + ); #[cfg(all(feature = "builtin.suspend", unix))] - m.insert("suspend".into(), builtin::()); + m.insert( + "suspend".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.test")] - m.insert("test".into(), builtin::()); + m.insert("test".into(), spec_builtin::()); #[cfg(feature = "builtin.test")] - m.insert("[".into(), builtin::()); + m.insert("[".into(), spec_builtin::()); #[cfg(feature = "builtin.declare")] m.insert( "typeset".into(), - decl_builtin::(), + spec_builtin::(), ); // Completion builtins #[cfg(feature = "builtin.complete")] m.insert( "complete".into(), - builtin::(), + spec_builtin::(), ); #[cfg(feature = "builtin.compgen")] - m.insert("compgen".into(), builtin::()); + m.insert( + "compgen".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.compopt")] - m.insert("compopt".into(), builtin::()); + m.insert( + "compopt".into(), + spec_builtin::(), + ); // Dir stack builtins #[cfg(feature = "builtin.dirs")] - m.insert("dirs".into(), builtin::()); + m.insert("dirs".into(), spec_builtin::()); #[cfg(feature = "builtin.popd")] - m.insert("popd".into(), builtin::()); + m.insert("popd".into(), spec_builtin::()); #[cfg(feature = "builtin.pushd")] - m.insert("pushd".into(), builtin::()); + m.insert("pushd".into(), spec_builtin::()); // Input configuration builtins #[cfg(feature = "builtin.bind")] - m.insert("bind".into(), builtin::()); + m.insert("bind".into(), spec_builtin::()); // History #[cfg(feature = "builtin.history")] - m.insert("history".into(), builtin::()); + m.insert( + "history".into(), + spec_builtin::(), + ); #[cfg(feature = "builtin.caller")] - m.insert("caller".into(), builtin::()); + m.insert("caller".into(), spec_builtin::()); // TODO(disown): implement disown builtin m.insert( "disown".into(), - builtin::(), + spec_builtin::(), ); // TODO(logout): implement logout builtin m.insert( "logout".into(), - builtin::(), + spec_builtin::(), ); } diff --git a/brush-builtins/src/fc.rs b/brush-builtins/src/fc.rs index 6ae9c22c5..0c3c5dbab 100644 --- a/brush-builtins/src/fc.rs +++ b/brush-builtins/src/fc.rs @@ -1,8 +1,10 @@ -use bpaf::Parser; -use std::ffi::OsStr; use std::io::Write; -use brush_core::{ExecutionResult, builtins, error, history}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, CommandSpec}, + builtins, error, history, +}; /// Process command history list. pub(crate) struct FcCommand { @@ -31,39 +33,63 @@ pub(crate) struct FcCommand { last: Option, } -impl builtins::Command for FcCommand { +const ID_LIST: &str = "list"; +const ID_NO_LINE_NUMBERS: &str = "no_line_numbers"; +const ID_REVERSE: &str = "reverse"; +const ID_SUBSTITUTE: &str = "substitute"; +const ID_EDITOR: &str = "editor"; + +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + ID_LIST, + &['l'], + &[], + "List commands instead of editing them.", + ), + ArgSpec::flag( + ID_NO_LINE_NUMBERS, + &['n'], + &[], + "Suppress line numbers when listing.", + ), + ArgSpec::flag(ID_REVERSE, &['r'], &[], "Reverse the order of commands."), + ArgSpec::flag( + ID_SUBSTITUTE, + &['s'], + &[], + "Re-execute command after substitution (old=new format).", + ), + ArgSpec::value( + ID_EDITOR, + &['e'], + &[], + "ENAME", + "Editor to use (only relevant when not listing or substituting).", + ), + ], + positionals: &[], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let mut trailing = values.trailing().iter(); + + Ok(Self { + list: values.flag(ID_LIST), + no_line_numbers: values.flag(ID_NO_LINE_NUMBERS), + reverse: values.flag(ID_REVERSE), + substitute: values.flag(ID_SUBSTITUTE), + editor: values.value(ID_EDITOR).map(str::to_string), + first: trailing.next().cloned(), + last: trailing.next().cloned(), }) } @@ -83,7 +109,7 @@ impl builtins::Command for FcCommand { "e" } - // N.B. Overrides the default [`builtins::Command::new`] so that negative + // N.B. Overrides the default [`builtins::SpecCommand::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 @@ -139,20 +165,10 @@ impl builtins::Command for FcCommand { options.push(arg); } - let mut command = run_bpaf_parser::(&options)?; - command.set_trailing_args(trailing); + let mut values = builtins::argmodel::backend().parse(Self::spec(), "", &options)?; + values.set_trailing(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); - } + Self::from_matches(&mut values) } async fn execute( @@ -426,38 +442,11 @@ 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 _; + use brush_core::builtins::SpecCommand as _; fn new_from(args: &[&str]) -> Result { FcCommand::new(std::iter::once("fc".to_string()).chain(args.iter().map(|s| s.to_string()))) diff --git a/brush-builtins/src/fg.rs b/brush-builtins/src/fg.rs index da307ed16..81dab84e0 100644 --- a/brush-builtins/src/fg.rs +++ b/brush-builtins/src/fg.rs @@ -1,22 +1,34 @@ -use bpaf::Bpaf; - use std::io::Write; -use brush_core::{ExecutionResult, builtins, jobs, sys}; +use brush_core::{ExecutionResult, argmodel::PositionalSpec, builtins, jobs, sys}; /// Move a specified job to the foreground. -#[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 { +const ID_JOB_SPEC: &str = "job_spec"; + +impl builtins::SpecCommand for FgCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - fg_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::one(ID_JOB_SPEC, "JOB_SPEC")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + job_spec: values + .value_of_positional(ID_JOB_SPEC) + .map(ToOwned::to_owned), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/getopts.rs b/brush-builtins/src/getopts.rs index 194022856..de3f815ee 100644 --- a/brush-builtins/src/getopts.rs +++ b/brush-builtins/src/getopts.rs @@ -1,6 +1,8 @@ use std::{collections::HashMap, io::Write}; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, env, variables}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, argmodel::CommandSpec, builtins, env, variables, +}; /// Parse command options. pub(crate) struct GetOptsCommand { @@ -78,23 +80,29 @@ fn parse_option_spec(spec: &str) -> OptionSpec { } } -impl builtins::Command for GetOptsCommand { +impl builtins::SpecCommand for GetOptsCommand { 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`. 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); + fn spec() -> &'static CommandSpec { + &CommandSpec::EMPTY + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + // N.B. The two required operands are validated in `execute`. + let trailing = values.trailing(); + let missing_operands = trailing.len() < 2; + + let mut iter = trailing.iter(); + let options_string = iter.next().cloned().unwrap_or_default(); + let variable_name = iter.next().cloned().unwrap_or_default(); - bpaf::construct!(GetOptsCommand { + Ok(Self { + missing_operands, options_string, variable_name, - args, - missing_operands, + args: iter.cloned().collect(), }) } @@ -110,19 +118,6 @@ impl builtins::Command for GetOptsCommand { 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; - } - 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>, diff --git a/brush-builtins/src/hash.rs b/brush-builtins/src/hash.rs index eee550427..183ee7186 100644 --- a/brush-builtins/src/hash.rs +++ b/brush-builtins/src/hash.rs @@ -1,40 +1,77 @@ -use bpaf::Bpaf; use std::{io::Write, path::PathBuf}; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; -#[derive(Bpaf)] pub(crate) struct HashCommand { - /// Remove entries associated with the given names. - #[bpaf(short('d'))] remove: bool, - - /// Display paths in a format usable for input. - #[bpaf(short('l'))] display_as_usable_input: bool, - - /// The path to associate with the names. - #[bpaf(short('p'), argument("PATH"))] path_to_use: Option, - - /// Remove all entries. - #[bpaf(short('r'))] remove_all: bool, - - /// Display the paths associated with the names. - #[bpaf(short('t'))] display_paths: bool, - - /// Names to process. - #[bpaf(positional("NAMES"))] names: Vec, } -impl builtins::Command for HashCommand { +const ID_REMOVE: &str = "remove"; +const ID_DISPLAY_AS_USABLE_INPUT: &str = "display_as_usable_input"; +const ID_PATH_TO_USE: &str = "path_to_use"; +const ID_REMOVE_ALL: &str = "remove_all"; +const ID_DISPLAY_PATHS: &str = "display_paths"; +const ID_NAMES: &str = "names"; + +impl builtins::SpecCommand for HashCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - hash_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag( + ID_REMOVE, + &['d'], + &[], + "Remove entries associated with the given names.", + ), + ArgSpec::flag( + ID_DISPLAY_AS_USABLE_INPUT, + &['l'], + &[], + "Display paths in a format usable for input.", + ), + ArgSpec::value( + ID_PATH_TO_USE, + &['p'], + &[], + "PATH", + "The path to associate with the names.", + ), + ArgSpec::flag(ID_REMOVE_ALL, &['r'], &[], "Remove all entries."), + ArgSpec::flag( + ID_DISPLAY_PATHS, + &['t'], + &[], + "Display the paths associated with the names.", + ), + ], + positionals: &[PositionalSpec::many(ID_NAMES, "NAMES")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + remove: values.flag(ID_REMOVE), + display_as_usable_input: values.flag(ID_DISPLAY_AS_USABLE_INPUT), + path_to_use: values.value(ID_PATH_TO_USE).map(PathBuf::from), + remove_all: values.flag(ID_REMOVE_ALL), + display_paths: values.flag(ID_DISPLAY_PATHS), + names: values.positional_values(ID_NAMES).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/help.rs b/brush-builtins/src/help.rs index a381d01fb..1c1524460 100644 --- a/brush-builtins/src/help.rs +++ b/brush-builtins/src/help.rs @@ -1,5 +1,8 @@ -use bpaf::Parser; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; use itertools::Itertools; use std::io::Write; @@ -11,28 +14,50 @@ pub(crate) struct HelpCommand { topic_patterns: Vec, } -impl builtins::Command for HelpCommand { +const ID_SHORT_DESCRIPTION: &str = "short_description"; +const ID_MAN_PAGE_STYLE: &str = "man_page_style"; +const ID_SHORT_USAGE: &str = "short_usage"; +const ID_TOPIC_PATTERNS: &str = "topic_patterns"; + +impl builtins::SpecCommand 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 spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag( + ID_SHORT_DESCRIPTION, + &['d'], + &[], + "Display a short description for the commands.", + ), + ArgSpec::flag( + ID_MAN_PAGE_STYLE, + &['m'], + &[], + "Display a man-style page of documentation for the commands.", + ), + ArgSpec::flag( + ID_SHORT_USAGE, + &['s'], + &[], + "Display a short usage summary for the commands.", + ), + ], + positionals: &[PositionalSpec::many(ID_TOPIC_PATTERNS, "PATTERNS")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + short_description: values.flag(ID_SHORT_DESCRIPTION), + man_page_style: values.flag(ID_MAN_PAGE_STYLE), + short_usage: values.flag(ID_SHORT_USAGE), + topic_patterns: values.positional_values(ID_TOPIC_PATTERNS).to_vec(), }) } diff --git a/brush-builtins/src/history.rs b/brush-builtins/src/history.rs index ce119ca19..317c8b6c6 100644 --- a/brush-builtins/src/history.rs +++ b/brush-builtins/src/history.rs @@ -1,11 +1,13 @@ -use bpaf::Parser; -use std::ffi::OsStr; use std::{ io::Write, path::{Path, PathBuf}, }; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, CommandSpec, ParsedValues}, + builtins, error, history, +}; /// Query or manipulate the shell's command history. // TODO(history): Evaluate which of the options conflict with each other. @@ -45,58 +47,116 @@ struct HistoryConfig { time_format: Option, } -impl builtins::Command for HistoryCommand { +const ID_CLEAR_HISTORY: &str = "clear_history"; +const ID_DELETE_OFFSET: &str = "delete_offset"; +const ID_APPEND_SESSION_TO_FILE: &str = "append_session_to_file"; +const ID_APPEND_REST_OF_FILE_TO_SESSION: &str = "append_rest_of_file_to_session"; +const ID_APPEND_FILE_TO_SESSION: &str = "append_file_to_session"; +const ID_WRITE_SESSION_TO_FILE: &str = "write_session_to_file"; +const ID_EXPAND_ARGS: &str = "expand_args"; +const ID_APPEND_ARGS_TO_SESSION: &str = "append_args_to_session"; + +impl builtins::SpecCommand 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, + fn spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag(ID_CLEAR_HISTORY, &['c'], &[], "Clears all history."), + ArgSpec::value( + ID_DELETE_OFFSET, + &['d'], + &[], + "OFFSET", + "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.", + ), + ArgSpec::value( + ID_APPEND_SESSION_TO_FILE, + &['a'], + &[], + "HIST_FILE", + "Appends the history from the current session to the history file.", + ), + ArgSpec::value( + ID_APPEND_REST_OF_FILE_TO_SESSION, + &['n'], + &[], + "HIST_FILE", + "Appends any remaining history from the history file to the current session.", + ), + ArgSpec::value( + ID_APPEND_FILE_TO_SESSION, + &['r'], + &[], + "HIST_FILE", + "Appends the history from the history file to the current session.", + ), + ArgSpec::value( + ID_WRITE_SESSION_TO_FILE, + &['w'], + &[], + "HIST_FILE", + "Replaces the history file with the current session history.", + ), + ArgSpec::flag( + ID_EXPAND_ARGS, + &['p'], + &[], + "History-expands positional arguments and displays them.", + ), + ArgSpec::flag( + ID_APPEND_ARGS_TO_SESSION, + &['s'], + &[], + "Appends positional arguments as an entry in the current session.", + ), + ], + positionals: &[], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let delete_offset = match values.value(ID_DELETE_OFFSET) { + Some(v) => Some( + v.parse::() + .map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid offset: {v}"), + help_request: false, + })?, + ), + None => None, + }; + + let append_args_to_session: Option> = + values.flag(ID_APPEND_ARGS_TO_SESSION).then(Vec::new); + let trailing = values.trailing().to_vec(); + + Ok(Self { + clear_history: values.flag(ID_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, + append_session_to_file: hist_file_state(values, ID_APPEND_SESSION_TO_FILE), + append_rest_of_file_to_session: hist_file_state( + values, + ID_APPEND_REST_OF_FILE_TO_SESSION, + ), + append_file_to_session: hist_file_state(values, ID_APPEND_FILE_TO_SESSION), + write_session_to_file: hist_file_state(values, ID_WRITE_SESSION_TO_FILE), + expand_args: values.flag(ID_EXPAND_ARGS).then(Vec::new), + append_args_to_session: if append_args_to_session.is_some() { + Some(trailing.clone()) + } else { + None + }, + args: if append_args_to_session.is_some() { + Vec::new() + } else { + trailing + }, }) } @@ -116,9 +176,11 @@ impl builtins::Command for HistoryCommand { "danrw" } - // N.B. Overrides the default [`builtins::Command::new`] so that flag-looking + // N.B. Overrides the default [`builtins::SpecCommand::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. + // joined into `-d=-3`, and so that the bare forms of `-a`/`-n`/`-r`/`-w` + // (whose HIST_FILE values are optional) are accepted alongside their + // value-taking forms. fn new(args: I) -> Result where I: IntoIterator, @@ -131,21 +193,57 @@ impl builtins::Command for HistoryCommand { } join_tokens_taking_values(&mut args, Self::value_taking_short_options()); - let (options, trailing) = + let (mut options, trailing) = builtins::split_option_section(&args, Self::value_taking_short_options(), &[]); - let mut command = run_bpaf_parser::(&options)?; - command.set_trailing_args(trailing); + // N.B. `-a`/`-n`/`-r`/`-w` take an *optional* HIST_FILE value. Lift the + // bare form (and any separately supplied value) out of the option section + // before parsing, since declared value-taking options require a value. + let mut lifted_file_options: Vec<(&'static str, Option)> = Vec::new(); + let mut i = 0; + while i < options.len() { + let tok = &options[i]; + if tok.len() != 2 || !tok.starts_with('-') { + i += 1; + continue; + } - Ok(command) - } + // N.B. Only single-letter bare tokens are lifted here; grouped + // forms (e.g., `-an`) keep their attached-value semantics. + let Some(short_char) = tok.chars().nth(1) else { + i += 1; + continue; + }; + + let Some(id) = hist_file_option_id(short_char) else { + i += 1; + continue; + }; + + let takes_separate_value = options + .get(i + 1) + .is_some_and(|next| !next.starts_with('-') || next == "-"); + + if takes_separate_value { + let value = options.remove(i + 1); + lifted_file_options.push((id, Some(value))); + } else { + lifted_file_options.push((id, None)); + } + options.remove(i); + } - 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; + let mut values = builtins::argmodel::backend().parse(Self::spec(), "", &options)?; + + for (id, value) in lifted_file_options { + match value { + Some(v) => values.push_value(id, v), + None => values.set_flag(id), + } } + values.set_trailing(trailing); + + Self::from_matches(&mut values) } async fn execute( @@ -314,7 +412,8 @@ fn get_effective_history_file_path<'a>( } /// Merges `-X` tokens followed by a flag-looking value token into `-X=` -/// so that bpaf accepts values that would otherwise be rejected as flags; +/// so that the argument backend 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; @@ -342,57 +441,34 @@ fn join_tokens_taking_values(args: &mut Vec, shorts: &str) { } } -/// 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) +/// Returns the declaration id for one of the optional-value `-a`/`-n`/`-r`/`-w` +/// history-file options. +const fn hist_file_option_id(short_char: char) -> Option<&'static str> { + match short_char { + 'a' => Some(ID_APPEND_SESSION_TO_FILE), + 'n' => Some(ID_APPEND_REST_OF_FILE_TO_SESSION), + 'r' => Some(ID_APPEND_FILE_TO_SESSION), + 'w' => Some(ID_WRITE_SESSION_TO_FILE), + _ => None, + } } -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, - }, +/// Reads back the tri-state state of one of the `-a`/`-n`/`-r`/`-w` options: +/// absent, present-without-a-value, or present-with-a-value. +#[expect(clippy::option_option)] +fn hist_file_state(values: &ParsedValues, id: &str) -> Option> { + if let Some(value) = values.value(id) { + return Some(Some(value.to_string())); } + + values.flag(id).then_some(None) } #[cfg(test)] mod tests { use super::*; use anyhow::Result; - use brush_core::builtins::Command as _; + use brush_core::builtins::SpecCommand as _; use pretty_assertions::{assert_eq, assert_matches}; fn new_from(args: &[&str]) -> Result { diff --git a/brush-builtins/src/jobs.rs b/brush-builtins/src/jobs.rs index 4926f6202..a9514b6d9 100644 --- a/brush-builtins/src/jobs.rs +++ b/brush-builtins/src/jobs.rs @@ -1,43 +1,63 @@ -use bpaf::Bpaf; - use std::io::Write; -use brush_core::{ExecutionResult, builtins, error, jobs}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, error, jobs, +}; /// Manage jobs. -#[derive(Bpaf)] pub(crate) struct JobsCommand { - /// Also show process IDs. - #[bpaf(short('l'))] also_show_pids: bool, - - /// List only jobs that have changed status since the last notification. - #[bpaf(short('n'))] list_changed_only: bool, - - /// Show only process IDs. - #[bpaf(short('p'))] show_pids_only: bool, - - /// Show only running jobs. - #[bpaf(short('r'))] running_jobs_only: bool, - - /// Show only stopped jobs. - #[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 { +const ID_ALSO_SHOW_PIDS: &str = "also_show_pids"; +const ID_LIST_CHANGED_ONLY: &str = "list_changed_only"; +const ID_SHOW_PIDS_ONLY: &str = "show_pids_only"; +const ID_RUNNING_JOBS_ONLY: &str = "running_jobs_only"; +const ID_STOPPED_JOBS_ONLY: &str = "stopped_jobs_only"; +const ID_JOB_SPECS: &str = "job_specs"; + +impl builtins::SpecCommand for JobsCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - jobs_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + // TODO(jobs): Add -x option + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag(ID_ALSO_SHOW_PIDS, &['l'], &[], "Also show process IDs."), + ArgSpec::flag( + ID_LIST_CHANGED_ONLY, + &['n'], + &[], + "List only jobs that have changed status since the last notification.", + ), + ArgSpec::flag(ID_SHOW_PIDS_ONLY, &['p'], &[], "Show only process IDs."), + ArgSpec::flag(ID_RUNNING_JOBS_ONLY, &['r'], &[], "Show only running jobs."), + ArgSpec::flag(ID_STOPPED_JOBS_ONLY, &['s'], &[], "Show only stopped jobs."), + ], + positionals: &[PositionalSpec::many(ID_JOB_SPECS, "JOB_SPECS")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + also_show_pids: values.flag(ID_ALSO_SHOW_PIDS), + list_changed_only: values.flag(ID_LIST_CHANGED_ONLY), + show_pids_only: values.flag(ID_SHOW_PIDS_ONLY), + running_jobs_only: values.flag(ID_RUNNING_JOBS_ONLY), + stopped_jobs_only: values.flag(ID_STOPPED_JOBS_ONLY), + job_specs: values.positional_values(ID_JOB_SPECS).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/kill.rs b/brush-builtins/src/kill.rs index 50ae79871..e7434f0ef 100644 --- a/brush-builtins/src/kill.rs +++ b/brush-builtins/src/kill.rs @@ -1,8 +1,11 @@ -use bpaf::Parser; -use std::{ffi::OsStr, io::Write}; +use std::io::Write; use brush_core::traps::TrapSignal; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, CommandSpec}, + builtins, sys, +}; /// Signal a job or process. pub(crate) struct KillCommand { @@ -20,35 +23,62 @@ pub(crate) struct KillCommand { args: Vec, } -impl builtins::Command for KillCommand { +const ID_SIGNAL_NAME: &str = "signal_name"; +const ID_SIGNAL_NUMBER: &str = "signal_number"; +const ID_LIST_SIGNALS: &str = "list_signals"; + +impl builtins::SpecCommand 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, + fn spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + // N.B. `-L` is a hidden alias for `-l`. + args: &[ + ArgSpec::value( + ID_SIGNAL_NAME, + &['s'], + &[], + "SIG_NAME", + "Name of the signal to send.", + ), + ArgSpec::value( + ID_SIGNAL_NUMBER, + &['n'], + &[], + "SIG_NUM", + "Number of the signal to send.", + ), + ArgSpec::flag( + ID_LIST_SIGNALS, + &['l', 'L'], + &[], + "List known signal names.", + ), + ], + positionals: &[], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let signal_number = match values.value(ID_SIGNAL_NUMBER) { + Some(v) => Some( + v.parse::() + .map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid signal number: {v}"), + help_request: false, + })?, + ), + None => None, + }; + + Ok(Self { + signal_name: values.value(ID_SIGNAL_NAME).map(str::to_string), signal_number, - list_signals, - args, + list_signals: values.flag(ID_LIST_SIGNALS), + args: values.trailing().to_vec(), }) } @@ -68,7 +98,7 @@ impl builtins::Command for KillCommand { "sn" } - /// N.B. Overrides the default [`builtins::Command::new`] because `-sigspec` + /// N.B. Overrides the default [`builtins::SpecCommand::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. @@ -128,14 +158,10 @@ impl builtins::Command for KillCommand { options.push(arg); } - let mut command = run_bpaf_parser::(&options)?; - command.set_trailing_args(trailing); - - Ok(command) - } + let mut values = builtins::argmodel::backend().parse(Self::spec(), "", &options)?; + values.set_trailing(trailing); - fn set_trailing_args(&mut self, args: Vec) { - self.args = args; + Self::from_matches(&mut values) } async fn execute( @@ -287,38 +313,11 @@ 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 _; + use brush_core::builtins::SpecCommand as _; #[test] fn parse_s_with_name() -> anyhow::Result<()> { diff --git a/brush-builtins/src/let_.rs b/brush-builtins/src/let_.rs index e73618caa..0c6efc4f3 100644 --- a/brush-builtins/src/let_.rs +++ b/brush-builtins/src/let_.rs @@ -1,6 +1,8 @@ use std::io::Write; -use brush_core::{ExecutionExitCode, ExecutionResult, arithmetic::Evaluatable, builtins}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, argmodel::CommandSpec, arithmetic::Evaluatable, builtins, +}; /// Evaluate arithmetic expressions. pub(crate) struct LetCommand { @@ -8,15 +10,19 @@ pub(crate) struct LetCommand { exprs: Vec, } -impl builtins::Command for LetCommand { +impl builtins::SpecCommand 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()); + fn spec() -> &'static CommandSpec { + &CommandSpec::EMPTY + } - bpaf::construct!(LetCommand { exprs }) + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + exprs: values.trailing().to_vec(), + }) } fn about() -> &'static str { @@ -31,10 +37,6 @@ impl builtins::Command for LetCommand { 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 30b4c438c..15532f546 100644 --- a/brush-builtins/src/lib.rs +++ b/brush-builtins/src/lib.rs @@ -123,33 +123,19 @@ mod unimp; pub use builder::ShellBuilderExt; pub use factory::{BuiltinSet, default_builtins}; -/// 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 -/// -/// - `$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. -pub(crate) fn minus_or_plus_flag( - flag_char: char, - plus_form: &'static str, - desc: &'static str, -) -> impl bpaf::Parser> { - use bpaf::Parser; - - 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)); - - bpaf::construct!([enable, disable]).fallback(None) +/// Reads back an optional enable/disable toggle declared as a flag and hidden +/// flag pair (see `SpecCommand::spec` implementations): `None` when neither is +/// present, `Some(true)` for the enable form, `Some(false)` for `+x`. +pub(crate) fn read_plus_minus( + values: &brush_core::argmodel::ParsedValues, + enable_id: &str, + disable_id: &str, +) -> Option { + let enable = values.flag(enable_id); + let disable = values.flag(disable_id); + match (enable, disable) { + (true, _) => Some(true), + (_, true) => Some(false), + _ => None, + } } diff --git a/brush-builtins/src/mapfile.rs b/brush-builtins/src/mapfile.rs index 326ed742f..75355046e 100644 --- a/brush-builtins/src/mapfile.rs +++ b/brush-builtins/src/mapfile.rs @@ -1,8 +1,10 @@ -use bpaf::Parser; -use std::ffi::OsStr; use std::io::{Read, Write}; -use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables}; +use brush_core::{ + ErrorKind, ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, CommandSpec, PositionalSpec}, + builtins, env, error, variables, +}; /// Read lines from standard input into an indexed array variable. pub(crate) struct MapFileCommand { @@ -34,48 +36,135 @@ pub(crate) struct MapFileCommand { array_var_name: String, } -impl builtins::Command for MapFileCommand { +const ID_DELIMITER: &str = "delimiter"; +const ID_MAX_COUNT: &str = "max_count"; +const ID_ORIGIN: &str = "origin"; +const ID_SKIP_COUNT: &str = "skip_count"; +const ID_REMOVE_DELIMITER: &str = "remove_delimiter"; +const ID_FD: &str = "fd"; +const ID_CALLBACK: &str = "callback"; +const ID_CALLBACK_GROUP_SIZE: &str = "callback_group_size"; +const ID_ARRAY_VAR_NAME: &str = "array_var_name"; + +impl builtins::SpecCommand 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 { + fn spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::value( + ID_DELIMITER, + &['d'], + &[], + "DELIM", + "Delimiter to use (defaults to newline).", + ), + ArgSpec::value( + ID_MAX_COUNT, + &['n'], + &[], + "COUNT", + "Maximum number of entries to read (0 means no limit).", + ), + ArgSpec::value( + ID_ORIGIN, + &['O'], + &[], + "ORIGIN", + "Index into array at which to start assignment.", + ), + ArgSpec::value( + ID_SKIP_COUNT, + &['s'], + &[], + "COUNT", + "Number of initial entries to skip.", + ), + ArgSpec::flag( + ID_REMOVE_DELIMITER, + &['t'], + &[], + "Whether or not to remove the delimiter from each read line.", + ), + ArgSpec::value( + ID_FD, + &['u'], + &[], + "FD", + "File descriptor to read from (defaults to stdin).", + ), + ArgSpec::value( + ID_CALLBACK, + &['C'], + &[], + "CALLBACK", + "Name of function to call for each group of lines.", + ), + ArgSpec::value( + ID_CALLBACK_GROUP_SIZE, + &['c'], + &[], + "COUNT", + "Number of lines to pass the callback for each group.", + ), + ], + positionals: &[PositionalSpec::one(ID_ARRAY_VAR_NAME, "ARRAY_VAR_NAME")], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let delimiter = values.value(ID_DELIMITER).map(str::to_string); + let max_count = match values.value(ID_MAX_COUNT) { + Some(v) => parse_i64(v)?, + None => 0, + }; + let origin = match values.value(ID_ORIGIN) { + Some(v) => Some(parse_i64(v)?), + None => None, + }; + let skip_count = match values.value(ID_SKIP_COUNT) { + Some(v) => { + let parsed = parse_i64(v)?; + if parsed < 0 { + return Err(builtins::BuiltinArgParseError { + message: format!("-s: must be >= 0: {v}"), + help_request: false, + }); + } + parsed + } + None => 0, + }; + let remove_delimiter = values.flag(ID_REMOVE_DELIMITER); + let fd = match values.value(ID_FD) { + Some(v) => v + .parse::() + .map_err(|_| invalid_number(v))?, + None => 0, + }; + let callback = values.value(ID_CALLBACK).map(str::to_string); + let callback_group_size = match values.value(ID_CALLBACK_GROUP_SIZE) { + Some(v) => { + let parsed = parse_i64(v)?; + if parsed < 1 { + return Err(builtins::BuiltinArgParseError { + message: format!("-c: must be >= 1: {v}"), + help_request: false, + }); + } + parsed + } + None => 5000, + }; + let array_var_name = values + .positional_values(ID_ARRAY_VAR_NAME) + .last() + .map_or_else(|| String::from("MAPFILE"), |value| value.clone()); + + Ok(Self { delimiter, max_count, origin, @@ -96,9 +185,13 @@ impl builtins::Command for MapFileCommand { "[-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 + fn value_taking_short_options() -> &'static str { + "dnOsuCc" + } + + // N.B. Overrides the default [`builtins::SpecCommand::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. + // into `-O=-3`; the backend otherwise rejects separate flag-shaped values. fn new(args: I) -> Result where I: IntoIterator, @@ -111,7 +204,9 @@ impl builtins::Command for MapFileCommand { } join_tokens_taking_values(&mut args, "O"); - run_bpaf_parser::(&args) + let mut values = builtins::argmodel::backend().parse(Self::spec(), "", &args)?; + + Self::from_matches(&mut values) } async fn execute( @@ -261,7 +356,8 @@ fn setup_terminal_settings( } /// Merges `-X` tokens followed by a flag-looking value token into `-X=` -/// so that bpaf accepts values that would otherwise be rejected as flags; +/// so that the argument backend 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; @@ -289,30 +385,15 @@ fn join_tokens_taking_values(args: &mut Vec, shorts: &str) { } } -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) +/// Parses an `i64` option value, reporting a parse failure on invalid input. +fn parse_i64(value: &str) -> Result { + value.parse::().map_err(|_| invalid_number(value)) } -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, - }, +fn invalid_number(value: &str) -> builtins::BuiltinArgParseError { + builtins::BuiltinArgParseError { + message: format!("invalid number: {value}"), + help_request: false, } } @@ -320,7 +401,7 @@ fn render_bpaf_failure(failure: bpaf::ParseFailure) -> builtins::BuiltinArgParse #[expect(clippy::panic_in_result_fn)] mod tests { use super::*; - use brush_core::builtins::Command as _; + use brush_core::builtins::SpecCommand as _; fn new_from(args: &[&str]) -> Result { MapFileCommand::new( diff --git a/brush-builtins/src/popd.rs b/brush-builtins/src/popd.rs index 07e3ac4d2..20268b5c9 100644 --- a/brush-builtins/src/popd.rs +++ b/brush-builtins/src/popd.rs @@ -1,22 +1,36 @@ -use bpaf::Bpaf; - -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ExecutionResult, argmodel::ArgSpec, builtins}; /// Pop a path from the current directory stack. -#[derive(Bpaf)] pub(crate) struct PopdCommand { - /// Pop the path without changing the current working directory. - #[bpaf(short('n'))] no_directory_change: bool, - // - // TODO(popd): implement +N and -N } -impl builtins::Command for PopdCommand { +const ID_NO_DIRECTORY_CHANGE: &str = "no_directory_change"; + +impl builtins::SpecCommand for PopdCommand { type Error = crate::dirs::DirError; - fn parser() -> impl bpaf::Parser { - popd_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + // TODO(popd): implement +N and -N + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ArgSpec::flag( + ID_NO_DIRECTORY_CHANGE, + &['n'], + &[], + "Pop the path without changing the current working directory.", + )], + positionals: &[], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + no_directory_change: values.flag(ID_NO_DIRECTORY_CHANGE), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/printf.rs b/brush-builtins/src/printf.rs index 329d7500c..21d30f720 100644 --- a/brush-builtins/src/printf.rs +++ b/brush-builtins/src/printf.rs @@ -1,9 +1,10 @@ -use bpaf::Parser; use std::{ffi::OsString, io::Write, ops::ControlFlow}; use uucore::format; use brush_core::{ - Error, ErrorKind, ExecutionExitCode, ExecutionResult, builtins, escape, expansion, + Error, ErrorKind, ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, CommandSpec}, + builtins, escape, expansion, }; /// Format a string. @@ -15,23 +16,31 @@ pub(crate) struct PrintfCommand { format_and_args: Vec, } -impl builtins::Command for PrintfCommand { +const ID_OUTPUT_VARIABLE: &str = "output_variable"; + +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ArgSpec::value( + ID_OUTPUT_VARIABLE, + &['v'], + &[], + "VAR", + "If specified, the output of the command is assigned to this variable.", + )], + positionals: &[], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + output_variable: values.value(ID_OUTPUT_VARIABLE).map(str::to_string), + format_and_args: values.trailing().to_vec(), }) } @@ -51,10 +60,6 @@ impl builtins::Command for PrintfCommand { "v" } - fn set_trailing_args(&mut self, args: Vec) { - self.format_and_args = args; - } - async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, diff --git a/brush-builtins/src/pushd.rs b/brush-builtins/src/pushd.rs index 263fd82c1..a467d700e 100644 --- a/brush-builtins/src/pushd.rs +++ b/brush-builtins/src/pushd.rs @@ -1,26 +1,51 @@ -use bpaf::Bpaf; - -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; /// Push a path onto the current directory stack. -#[derive(Bpaf)] pub(crate) struct PushdCommand { - /// Push the path without changing the current working directory. - #[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 } -impl builtins::Command for PushdCommand { +const ID_NO_DIRECTORY_CHANGE: &str = "no_directory_change"; +const ID_DIR: &str = "dir"; + +impl builtins::SpecCommand for PushdCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - pushd_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + // TODO(pushd): implement +N and -N + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ArgSpec::flag( + ID_NO_DIRECTORY_CHANGE, + &['n'], + &[], + "Push the path without changing the current working directory.", + )], + positionals: &[PositionalSpec::one(ID_DIR, "DIR")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let dir = + values + .value_of_positional(ID_DIR) + .ok_or_else(|| builtins::BuiltinArgParseError { + message: "missing required argument: DIR".to_string(), + help_request: false, + })?; + + Ok(Self { + no_directory_change: values.flag(ID_NO_DIRECTORY_CHANGE), + dir: dir.to_owned(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/pwd.rs b/brush-builtins/src/pwd.rs index 357874dc1..c38a6d64f 100644 --- a/brush-builtins/src/pwd.rs +++ b/brush-builtins/src/pwd.rs @@ -10,16 +10,19 @@ pub(crate) struct PwdCommand { mode: Option, } -impl builtins::Command for PwdCommand { +impl builtins::SpecCommand 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 spec() -> &'static builtins::argmodel::CommandSpec { + &SPEC } + fn from_matches( + _values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + // N.B. Parsing is fully handled by the overridden `new`. + unreachable!("pwd parses via overridden new()") + } fn about() -> &'static str { "Display the current working directory." } @@ -32,6 +35,8 @@ impl builtins::Command for PwdCommand { where I: IntoIterator, { + // N.B. Options are interpreted manually because their combined forms + // depend on ordering (`pwd -L -P` vs `pwd -P -L`). let mut args: Vec = args.into_iter().collect(); // N.B. The first argument is the command name itself. @@ -98,11 +103,12 @@ impl builtins::Command for PwdCommand { } } +static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec::EMPTY; + #[cfg(test)] -#[allow(clippy::panic_in_result_fn)] mod tests { use super::*; - use brush_core::builtins::Command as _; + use brush_core::builtins::SpecCommand as _; #[test] fn parse_modes() { diff --git a/brush-builtins/src/read.rs b/brush-builtins/src/read.rs index 8817d37f8..5a08dfbfe 100644 --- a/brush-builtins/src/read.rs +++ b/brush-builtins/src/read.rs @@ -1,10 +1,12 @@ -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}; +use brush_core::{ + ErrorKind, + argmodel::{ArgSpec, CommandSpec, PositionalSpec}, + builtins, env, error, variables, +}; use std::io::{Read, Write}; @@ -65,58 +67,127 @@ pub(crate) struct ReadCommand { variable_names: Vec, } -impl builtins::Command for ReadCommand { +const ID_ARRAY_VARIABLE: &str = "array_variable"; +const ID_DELIMITER: &str = "delimiter"; +const ID_USE_READLINE: &str = "use_readline"; +const ID_INITIAL_TEXT: &str = "initial_text"; +const ID_RETURN_AFTER_N_CHARS: &str = "return_after_n_chars"; +const ID_RETURN_AFTER_N_CHARS_NO_DELIMITER: &str = "return_after_n_chars_no_delimiter"; +const ID_PROMPT: &str = "prompt"; +const ID_RAW_MODE: &str = "raw_mode"; +const ID_SILENT: &str = "silent"; +const ID_TIMEOUT_IN_SECONDS: &str = "timeout_in_seconds"; +const ID_FD_NUM_TO_READ: &str = "fd_num_to_read"; +const ID_VARIABLE_NAMES: &str = "variable_names"; + +impl builtins::SpecCommand 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 { + fn spec() -> &'static CommandSpec { + static SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::value( + ID_ARRAY_VARIABLE, + &['a'], + &[], + "VAR_NAME", + "Optionally, name of an array variable to receive read words of input.", + ), + ArgSpec::value( + ID_DELIMITER, + &['d'], + &[], + "DELIM", + "Optionally, a delimiter to use other than a newline character.", + ), + ArgSpec::flag(ID_USE_READLINE, &['e'], &[], "Use readline-like input."), + ArgSpec::value( + ID_INITIAL_TEXT, + &['i'], + &[], + "STR", + "Provide text to use as initial input for readline.", + ), + ArgSpec::value( + ID_RETURN_AFTER_N_CHARS, + &['n'], + &[], + "COUNT", + "Read only the first N characters or until a specified delimiter is \ + reached, whichever happens first.", + ), + ArgSpec::value( + ID_RETURN_AFTER_N_CHARS_NO_DELIMITER, + &['N'], + &[], + "COUNT", + "Read exactly N characters, ignoring any specified delimiter.", + ), + ArgSpec::value( + ID_PROMPT, + &['p'], + &[], + "PROMPT", + "Prompt to display before reading.", + ), + ArgSpec::flag( + ID_RAW_MODE, + &['r'], + &[], + "Read input in raw mode; no escape sequences.", + ), + ArgSpec::flag(ID_SILENT, &['s'], &[], "Do not echo input."), + ArgSpec::value( + ID_TIMEOUT_IN_SECONDS, + &['t'], + &[], + "SECONDS", + "Specify timeout in seconds; fail if the timeout elapses before \ + input is completed.", + ), + ArgSpec::value( + ID_FD_NUM_TO_READ, + &['u'], + &[], + "FD", + "File descriptor to read from instead of stdin.", + ), + ], + positionals: &[PositionalSpec::many(ID_VARIABLE_NAMES, "VAR_NAMES")], + }; + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let array_variable = values.value(ID_ARRAY_VARIABLE).map(str::to_string); + let delimiter = values.value(ID_DELIMITER).map(str::to_string); + let use_readline = values.flag(ID_USE_READLINE); + let initial_text = values.value(ID_INITIAL_TEXT).map(str::to_string); + let return_after_n_chars = match values.value(ID_RETURN_AFTER_N_CHARS) { + Some(v) => Some(parse_usize(v)?), + None => None, + }; + let return_after_n_chars_no_delimiter = + match values.value(ID_RETURN_AFTER_N_CHARS_NO_DELIMITER) { + Some(v) => Some(parse_usize(v)?), + None => None, + }; + let prompt = values.value(ID_PROMPT).map(str::to_string); + let raw_mode = values.flag(ID_RAW_MODE); + let silent = values.flag(ID_SILENT); + let timeout_in_seconds = match values.value(ID_TIMEOUT_IN_SECONDS) { + Some(v) => Some(parse_f64(v)?), + None => None, + }; + let fd_num_to_read = match values.value(ID_FD_NUM_TO_READ) { + Some(v) => Some(parse_u8(v)?), + None => None, + }; + let variable_names = values.positional_values(ID_VARIABLE_NAMES).to_vec(); + + Ok(Self { array_variable, delimiter, use_readline, @@ -140,9 +211,13 @@ impl builtins::Command for ReadCommand { "[-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 + fn value_taking_short_options() -> &'static str { + "adinNptu" + } + + // N.B. Overrides the default [`builtins::SpecCommand::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. + // `-t=-0.5`; the backend otherwise rejects separate flag-shaped values. fn new(args: I) -> Result where I: IntoIterator, @@ -155,7 +230,9 @@ impl builtins::Command for ReadCommand { } join_tokens_taking_values(&mut args, "t"); - run_bpaf_parser::(&args) + let mut values = builtins::argmodel::backend().parse(Self::spec(), "", &args)?; + + Self::from_matches(&mut values) } async fn execute( @@ -765,7 +842,8 @@ fn split_line_by_ifs(ifs: &str, line: &str, max_fields: Option) -> VecDeq } /// Merges `-X` tokens followed by a flag-looking value token into `-X=` -/// so that bpaf accepts values that would otherwise be rejected as flags; +/// so that the argument backend 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; @@ -793,37 +871,32 @@ fn join_tokens_taking_values(args: &mut Vec, shorts: &str) { } } -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) +/// Parses a `usize` option value, reporting a parse failure on invalid input. +fn parse_usize(value: &str) -> Result { + value.parse::().map_err(|_| invalid_number(value)) +} + +/// Parses an `f64` option value, reporting a parse failure on invalid input. +fn parse_f64(value: &str) -> Result { + value.parse::().map_err(|_| invalid_number(value)) +} + +/// Parses a `u8` option value, reporting a parse failure on invalid input. +fn parse_u8(value: &str) -> Result { + value.parse::().map_err(|_| invalid_number(value)) } -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, - }, +fn invalid_number(value: &str) -> builtins::BuiltinArgParseError { + builtins::BuiltinArgParseError { + message: format!("invalid number: {value}"), + help_request: false, } } #[cfg(test)] #[expect(clippy::panic_in_result_fn)] mod tests { - use brush_core::builtins::Command as _; + use brush_core::builtins::SpecCommand as _; use itertools::assert_equal; use super::*; diff --git a/brush-builtins/src/return_.rs b/brush-builtins/src/return_.rs index acca8a357..dd36db027 100644 --- a/brush-builtins/src/return_.rs +++ b/brush-builtins/src/return_.rs @@ -1,21 +1,40 @@ -use bpaf::Bpaf; use std::io::Write; -use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; +use brush_core::{ + ExecutionControlFlow, ExecutionExitCode, ExecutionResult, argmodel::PositionalSpec, builtins, +}; /// Return from the current function. -#[derive(Bpaf)] pub(crate) struct ReturnCommand { - /// The exit code to return. - #[bpaf(positional("CODE"))] code: Option, } -impl builtins::Command for ReturnCommand { +const ID_CODE: &str = "code"; + +impl builtins::SpecCommand for ReturnCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - return_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::one(ID_CODE, "CODE")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let code = match values.value_of_positional(ID_CODE) { + Some(value) => Some(value.parse().map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid numeric value: {value}"), + help_request: false, + })?), + None => None, + }; + + Ok(Self { code }) } fn about() -> &'static str { diff --git a/brush-builtins/src/set.rs b/brush-builtins/src/set.rs index e024d8431..d1bfbe77f 100644 --- a/brush-builtins/src/set.rs +++ b/brush-builtins/src/set.rs @@ -1,46 +1,19 @@ -use bpaf::Parser; use std::collections::HashMap; -use std::ffi::OsStr; use std::io::Write; use itertools::Itertools; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues}; use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables}; /// Tri-state capture of a `set -o`/`+o` style option: absent, present with no /// value (list all), or present with a value. +#[derive(Default)] pub(crate) struct SetOption { enable: Option>, 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. pub(crate) struct SetCommand { export_variables_on_modification: Option, @@ -68,91 +41,206 @@ pub(crate) struct SetCommand { 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", +static SET_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + "set_a_enable", + &['a'], + &[], + "Export variables on modification", + ), + ArgSpec::hidden_flag("set_a_disable", &[], &["+a"], ""), + ArgSpec::flag( + "set_b_enable", + &['b'], + &[], + "Notify job termination immediately", + ), + ArgSpec::hidden_flag("set_b_disable", &[], &["+b"], ""), + ArgSpec::flag("set_e_enable", &['e'], &[], "Exit on nonzero command exit"), + ArgSpec::hidden_flag("set_e_disable", &[], &["+e"], ""), + ArgSpec::flag("set_f_enable", &['f'], &[], "Disable filename globbing"), + ArgSpec::hidden_flag("set_f_disable", &[], &["+f"], ""), + ArgSpec::flag("set_h_enable", &['h'], &[], "Remember command locations"), + ArgSpec::hidden_flag("set_h_disable", &[], &["+h"], ""), + ArgSpec::flag( + "set_k_enable", + &['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", + ), + ArgSpec::hidden_flag("set_k_disable", &[], &["+k"], ""), + ArgSpec::flag("set_m_enable", &['m'], &[], "Enable job control"), + ArgSpec::hidden_flag("set_m_disable", &[], &["+m"], ""), + ArgSpec::flag("set_n_enable", &['n'], &[], "Do not execute commands"), + ArgSpec::hidden_flag("set_n_disable", &[], &["+n"], ""), + ArgSpec::flag("set_p_enable", &['p'], &[], "Real effective UID mismatch"), + ArgSpec::hidden_flag("set_p_disable", &[], &["+p"], ""), + ArgSpec::flag("set_t_enable", &['t'], &[], "Exit after one command"), + ArgSpec::hidden_flag("set_t_disable", &[], &["+t"], ""), + ArgSpec::flag( + "set_u_enable", + &['u'], + &[], + "Treat unset variables as error", + ), + ArgSpec::hidden_flag("set_u_disable", &[], &["+u"], ""), + ArgSpec::flag("set_v_enable", &['v'], &[], "Print shell input lines"), + ArgSpec::hidden_flag("set_v_disable", &[], &["+v"], ""), + ArgSpec::flag("set_x_enable", &['x'], &[], "Print commands and arguments"), + ArgSpec::hidden_flag("set_x_disable", &[], &["+x"], ""), + ArgSpec::flag("set_B_enable", &['B'], &[], "Perform brace expansion"), + ArgSpec::hidden_flag("set_B_disable", &[], &["+B"], ""), + ArgSpec::flag( + "set_C_enable", + &['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 }) - }; + ), + ArgSpec::hidden_flag("set_C_disable", &[], &["+C"], ""), + ArgSpec::flag( + "set_E_enable", + &['E'], + &[], + "Shell functions inherit ERR trap", + ), + ArgSpec::hidden_flag("set_E_disable", &[], &["+E"], ""), + ArgSpec::flag( + "set_H_enable", + &['H'], + &[], + "Enable bang style history substitution", + ), + ArgSpec::hidden_flag("set_H_disable", &[], &["+H"], ""), + ArgSpec::flag( + "set_P_enable", + &['P'], + &[], + "Do not resolve symlinks when changing dir", + ), + ArgSpec::hidden_flag("set_P_disable", &[], &["+P"], ""), + ArgSpec::flag( + "set_T_enable", + &['T'], + &[], + "Shell functions inherit DEBUG and RETURN traps", + ), + ArgSpec::hidden_flag("set_T_disable", &[], &["+T"], ""), + // N.B. Declared for help rendering; `-o`/`+o` occurrences are + // extracted from the token stream before the backend parses (see + // `extract_named_options`). + ArgSpec::hidden_value( + "setopt_enable", + &['o'], + &[], + "OPT", + "Specify a named option; without OPT, lists all named options.", + ), + ArgSpec::hidden_value("setopt_disable", &[], &["+o"], "OPT", ""), + ], + positionals: &[], +}; + +impl builtins::SpecCommand for SetCommand { + type Error = brush_core::Error; - // 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 spec() -> &'static CommandSpec { + &SET_SPEC + } + + fn from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + export_variables_on_modification: crate::read_plus_minus( + values, + "set_a_enable", + "set_a_disable", + ), + notify_job_termination_immediately: crate::read_plus_minus( + values, + "set_b_enable", + "set_b_disable", + ), + exit_on_nonzero_command_exit: crate::read_plus_minus( + values, + "set_e_enable", + "set_e_disable", + ), + disable_filename_globbing: crate::read_plus_minus( + values, + "set_f_enable", + "set_f_disable", + ), + remember_command_locations: crate::read_plus_minus( + values, + "set_h_enable", + "set_h_disable", + ), + place_all_assignment_args_in_command_env: crate::read_plus_minus( + values, + "set_k_enable", + "set_k_disable", + ), + enable_job_control: crate::read_plus_minus(values, "set_m_enable", "set_m_disable"), + do_not_execute_commands: crate::read_plus_minus( + values, + "set_n_enable", + "set_n_disable", + ), + real_effective_uid_mismatch: crate::read_plus_minus( + values, + "set_p_enable", + "set_p_disable", + ), + exit_after_one_command: crate::read_plus_minus(values, "set_t_enable", "set_t_disable"), + treat_unset_variables_as_error: crate::read_plus_minus( + values, + "set_u_enable", + "set_u_disable", + ), + print_shell_input_lines: crate::read_plus_minus( + values, + "set_v_enable", + "set_v_disable", + ), + print_commands_and_arguments: crate::read_plus_minus( + values, + "set_x_enable", + "set_x_disable", + ), + perform_brace_expansion: crate::read_plus_minus( + values, + "set_B_enable", + "set_B_disable", + ), + disallow_overwriting_regular_files_via_output_redirection: crate::read_plus_minus( + values, + "set_C_enable", + "set_C_disable", + ), + shell_functions_inherit_err_trap: crate::read_plus_minus( + values, + "set_E_enable", + "set_E_disable", + ), + enable_bang_style_history_substitution: crate::read_plus_minus( + values, + "set_H_enable", + "set_H_disable", + ), + do_not_resolve_symlinks_when_changing_dir: crate::read_plus_minus( + values, + "set_P_enable", + "set_P_disable", + ), + shell_functions_inherit_debug_and_return_traps: crate::read_plus_minus( + values, + "set_T_enable", + "set_T_disable", + ), + + set_option: SetOption::default(), + positional_args: values.trailing().to_vec(), + double_dash_seen: false, }) } @@ -176,10 +264,14 @@ impl builtins::Command for SetCommand { "o" } - /// Overrides the default [`builtins::Command::new`] flow so that the presence + /// Overrides the default [`builtins::SpecCommand::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. + /// splitter drops `--` before the backend ever sees it, yet `set --` must + /// still clear the shell's positional parameters. + /// + /// It additionally expands `+`-style option groups and `-o` short-option + /// groups, extracts the `-o`/`+o` tri-state occurrences, and rewrites + /// remaining `+x` spellings into forms the argument backend can match. fn new(args: I) -> Result where I: IntoIterator, @@ -208,22 +300,24 @@ impl builtins::Command for SetCommand { 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)?; + let (mut options, enable, disable) = extract_named_options(options); + + // N.B. Rewrite `+x`-style spellings into the corresponding hidden long + // forms that the argument backend can match; this happens *after* + // splitting because the splitter classifies `--+x` as an operand. + rewrite_plus_flags(&mut options); + + let mut values = + brush_core::builtins::argmodel::backend().parse(Self::spec(), "", &options)?; - command.set_trailing_args(trailing); + let mut command = Self::from_matches(&mut values)?; + command.set_option = SetOption { enable, disable }; + command.positional_args = trailing; command.double_dash_seen = double_dash_seen; Ok(command) } - fn set_trailing_args(&mut self, args: Vec) { - self.positional_args = args; - } - #[expect(clippy::too_many_lines)] async fn execute( &self, @@ -450,27 +544,64 @@ 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, - }, +/// Extracts `-o`/`+o` occurrences from the option section, returning the +/// remaining tokens along with the enable/disable values. +/// +/// Mirrors the historical parser's tri-state semantics: an option absent +/// entirely maps to `None`; present occurrences accumulate any provided +/// values; a present occurrence with no value yields an empty vector, which +/// means "list all named options". +fn extract_named_options( + options: Vec, +) -> (Vec, Option>, Option>) { + let mut rest = Vec::with_capacity(options.len()); + let mut enable: Option> = None; + let mut disable: Option> = None; + let mut iter = options.into_iter().peekable(); + + while let Some(arg) = iter.next() { + if arg == "-o" || arg == "+o" { + // Consume a following word as the named option value unless it + // looks like another option itself. + let value = match iter.peek() { + Some(next) if !next.starts_with('-') && !next.starts_with('+') => iter.next(), + _ => None, + }; + + let slot = if arg == "-o" { + &mut enable + } else { + &mut disable + }; + let slot = slot.get_or_insert_with(Vec::new); + if let Some(value) = value { + slot.push(value); + } + } else if let Some(value) = arg.strip_prefix("-o=") { + enable.get_or_insert_with(Vec::new).push(value.to_owned()); + } else { + rest.push(arg); + } + } + + (rest, enable, disable) +} + +/// Rewrites `+x`-style tokens into the corresponding hidden long spellings +/// (e.g., `+x` becomes `--+x`) that the argument backend can match against +/// the disable-side arguments in this command's spec. +fn rewrite_plus_flags(options: &mut [String]) { + for arg in options.iter_mut() { + if let Some(group) = arg.strip_prefix('+').filter(|g| !g.is_empty()) { + if !group.starts_with('+') && !group.contains('=') { + *arg = format!("--+{group}"); + } + } } } /// 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('-') diff --git a/brush-builtins/src/shift.rs b/brush-builtins/src/shift.rs index eee2ecb09..b531580e4 100644 --- a/brush-builtins/src/shift.rs +++ b/brush-builtins/src/shift.rs @@ -1,20 +1,36 @@ -use bpaf::Parser; - -use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; +use brush_core::{ExecutionExitCode, ExecutionResult, argmodel::PositionalSpec, builtins}; /// Shift positional arguments. pub(crate) struct ShiftCommand { n: Option, } -impl builtins::Command for ShiftCommand { +const ID_N: &str = "n"; + +impl builtins::SpecCommand 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 spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[], + positionals: &[PositionalSpec::one(ID_N, "N")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let n = match values.value_of_positional(ID_N) { + Some(value) => Some(value.parse().map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid numeric value: {value}"), + help_request: false, + })?), + None => None, + }; + + Ok(Self { n }) } fn about() -> &'static str { diff --git a/brush-builtins/src/shopt.rs b/brush-builtins/src/shopt.rs index 2c6a3fe19..88075435a 100644 --- a/brush-builtins/src/shopt.rs +++ b/brush-builtins/src/shopt.rs @@ -1,9 +1,16 @@ -use bpaf::Parser; use itertools::Itertools; use std::io::Write; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues, PositionalSpec}; use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; +const ID_SET_O_NAMES_ONLY: &str = "set_o_names_only"; +const ID_PRINT: &str = "print"; +const ID_QUIET: &str = "quiet"; +const ID_SET: &str = "set"; +const ID_UNSET: &str = "unset"; +const ID_OPTIONS: &str = "options"; + /// Manage shopt-style options. pub(crate) struct ShoptCommand { set_o_names_only: bool, @@ -14,30 +21,32 @@ pub(crate) struct ShoptCommand { options: Vec, } -impl builtins::Command for ShoptCommand { +static SHOPT_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag(ID_SET_O_NAMES_ONLY, &['o'], &[], "Manage set -o options."), + ArgSpec::flag(ID_PRINT, &['p'], &[], "Print options' current values."), + ArgSpec::flag(ID_QUIET, &['q'], &[], "Suppress typical output."), + ArgSpec::flag(ID_SET, &['s'], &[], "Set the specified options."), + ArgSpec::flag(ID_UNSET, &['u'], &[], "Unset the specified options."), + ], + positionals: &[PositionalSpec::many(ID_OPTIONS, "OPTIONS")], +}; + +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + &SHOPT_SPEC + } + + fn from_matches(values: &mut ParsedValues) -> Result { + Ok(Self { + set_o_names_only: values.flag(ID_SET_O_NAMES_ONLY), + print: values.flag(ID_PRINT), + quiet: values.flag(ID_QUIET), + set: values.flag(ID_SET), + unset: values.flag(ID_UNSET), + options: values.positional_values(ID_OPTIONS).to_vec(), }) } diff --git a/brush-builtins/src/suspend.rs b/brush-builtins/src/suspend.rs index 283f98c18..0ff1b350d 100644 --- a/brush-builtins/src/suspend.rs +++ b/brush-builtins/src/suspend.rs @@ -1,21 +1,37 @@ -use bpaf::Bpaf; use std::io::Write; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; +use brush_core::{ExecutionExitCode, ExecutionResult, argmodel::ArgSpec, builtins}; /// Suspend the shell. -#[derive(Bpaf)] pub(crate) struct SuspendCommand { - /// Force suspend login shells. - #[bpaf(short('f'))] force: bool, } -impl builtins::Command for SuspendCommand { +const ID_FORCE: &str = "force"; + +impl builtins::SpecCommand for SuspendCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - suspend_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ArgSpec::flag( + ID_FORCE, + &['f'], + &[], + "Force suspend login shells.", + )], + positionals: &[], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + force: values.flag(ID_FORCE), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/test.rs b/brush-builtins/src/test.rs index d791b619a..b0401e324 100644 --- a/brush-builtins/src/test.rs +++ b/brush-builtins/src/test.rs @@ -1,7 +1,8 @@ use std::io::Write; use brush_core::{ - ErrorKind, ExecutionExitCode, ExecutionParameters, ExecutionResult, Shell, builtins, tests, + ErrorKind, ExecutionExitCode, ExecutionParameters, ExecutionResult, Shell, + argmodel::CommandSpec, builtins, tests, }; /// Evaluate test expression. @@ -10,18 +11,22 @@ pub(crate) struct TestCommand { args: Vec, } -impl builtins::Command for TestCommand { +impl builtins::SpecCommand for TestCommand { type Error = brush_core::Error; - 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()); + fn spec() -> &'static CommandSpec { + &CommandSpec::EMPTY + } - bpaf::construct!(TestCommand { args }) + fn from_matches( + _values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + unreachable!("new() captures all arguments verbatim") } + // N.B. Arguments are captured verbatim because test expressions are + // interpreted entirely by `execute`; the spec exists only for help + // rendering. fn new(args: I) -> Result where I: IntoIterator, @@ -95,7 +100,7 @@ fn execute_test( #[allow(clippy::panic_in_result_fn)] mod double_dash_tests { use super::*; - use brush_core::builtins::Command as _; + use brush_core::builtins::SpecCommand as _; #[test] fn captures_lone_double_dash() -> anyhow::Result<()> { diff --git a/brush-builtins/src/times.rs b/brush-builtins/src/times.rs index 1ce340133..5b76ccaff 100644 --- a/brush-builtins/src/times.rs +++ b/brush-builtins/src/times.rs @@ -6,11 +6,17 @@ use brush_core::{ExecutionResult, builtins, timing}; #[derive(Clone)] pub(crate) struct TimesCommand {} -impl builtins::Command for TimesCommand { +impl builtins::SpecCommand for TimesCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - bpaf::construct!(TimesCommand {}) + fn spec() -> &'static builtins::argmodel::CommandSpec { + &builtins::argmodel::CommandSpec::EMPTY + } + + fn from_matches( + _values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self {}) } fn about() -> &'static str { diff --git a/brush-builtins/src/trap.rs b/brush-builtins/src/trap.rs index 23be1816f..498300d13 100644 --- a/brush-builtins/src/trap.rs +++ b/brush-builtins/src/trap.rs @@ -1,30 +1,51 @@ -use bpaf::Bpaf; use std::io::Write; use brush_core::traps::TrapSignal; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; /// Manage signal traps. -#[derive(Bpaf)] pub(crate) struct TrapCommand { - /// List all signal names. - #[bpaf(short('l'))] list_signals: bool, - - /// Print registered trap commands. - #[bpaf(short('p'))] print_trap_commands: bool, - - /// Handler command and signals to operate on. - #[bpaf(positional("ARGS"))] args: Vec, } -impl builtins::Command for TrapCommand { +const ID_LIST_SIGNALS: &str = "list_signals"; +const ID_PRINT_TRAP_COMMANDS: &str = "print_trap_commands"; +const ID_ARGS: &str = "args"; + +impl builtins::SpecCommand for TrapCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - trap_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag(ID_LIST_SIGNALS, &['l'], &[], "List all signal names."), + ArgSpec::flag( + ID_PRINT_TRAP_COMMANDS, + &['p'], + &[], + "Print registered trap commands.", + ), + ], + positionals: &[PositionalSpec::many(ID_ARGS, "ARGS")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + list_signals: values.flag(ID_LIST_SIGNALS), + print_trap_commands: values.flag(ID_PRINT_TRAP_COMMANDS), + args: values.positional_values(ID_ARGS).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/type_.rs b/brush-builtins/src/type_.rs index fd421e58c..c4b9071bf 100644 --- a/brush-builtins/src/type_.rs +++ b/brush-builtins/src/type_.rs @@ -1,39 +1,31 @@ -use bpaf::Bpaf; use std::io::Write; use std::path::{Path, PathBuf}; use brush_core::sys::{self, fs::PathExt}; -use brush_core::{ExecutionResult, Shell, builtins, parser::ast}; +use brush_core::{ + ExecutionResult, Shell, + argmodel::{ArgSpec, PositionalSpec}, + builtins, + parser::ast, +}; /// Inspect the type of a named shell item. -#[derive(Bpaf)] pub(crate) struct TypeCommand { - /// Display all locations of the specified name, not just the first. - #[bpaf(short('a'))] all_locations: bool, - - /// Don't consider functions when resolving the name. - #[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. - #[bpaf(short('P'))] force_path_search: bool, - - /// Show file path only. - #[bpaf(short('p'))] show_path_only: bool, - - /// Only display the type of the specified name. - #[bpaf(short('t'))] type_only: bool, - - /// Names to search for. - #[bpaf(positional("NAMES"))] names: Vec, } +const ID_ALL_LOCATIONS: &str = "all_locations"; +const ID_SUPPRESS_FUNC_LOOKUP: &str = "suppress_func_lookup"; +const ID_FORCE_PATH_SEARCH: &str = "force_path_search"; +const ID_SHOW_PATH_ONLY: &str = "show_path_only"; +const ID_TYPE_ONLY: &str = "type_only"; +const ID_NAMES: &str = "names"; + enum ResolvedType<'a> { Alias(String), Keyword, @@ -42,11 +34,55 @@ enum ResolvedType<'a> { File { path: PathBuf, hashed: bool }, } -impl builtins::Command for TypeCommand { +impl builtins::SpecCommand for TypeCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - type_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag( + ID_ALL_LOCATIONS, + &['a'], + &[], + "Display all locations of the specified name, not just the first.", + ), + ArgSpec::flag( + ID_SUPPRESS_FUNC_LOOKUP, + &['f'], + &[], + "Don't consider functions when resolving the name.", + ), + ArgSpec::flag( + ID_FORCE_PATH_SEARCH, + &['P'], + &[], + "Force searching by file path, even if the name is an alias, built-in command, or shell function.", + ), + ArgSpec::flag(ID_SHOW_PATH_ONLY, &['p'], &[], "Show file path only."), + ArgSpec::flag( + ID_TYPE_ONLY, + &['t'], + &[], + "Only display the type of the specified name.", + ), + ], + positionals: &[PositionalSpec::many(ID_NAMES, "NAMES")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + all_locations: values.flag(ID_ALL_LOCATIONS), + suppress_func_lookup: values.flag(ID_SUPPRESS_FUNC_LOOKUP), + force_path_search: values.flag(ID_FORCE_PATH_SEARCH), + show_path_only: values.flag(ID_SHOW_PATH_ONLY), + type_only: values.flag(ID_TYPE_ONLY), + names: values.positional_values(ID_NAMES).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/ulimit.rs b/brush-builtins/src/ulimit.rs index 74b2a8a71..93364d678 100644 --- a/brush-builtins/src/ulimit.rs +++ b/brush-builtins/src/ulimit.rs @@ -1,10 +1,10 @@ -use bpaf::Parser; use std::{ - ffi::OsStr, + collections::HashMap, io::{self, ErrorKind, Write}, str::FromStr, }; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues, PositionalSpec}; use brush_core::{ExecutionResult, builtins}; #[derive(Clone, Copy)] @@ -307,17 +307,42 @@ 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() +/// Removes bare resource-limit switches (e.g., `-c` with no value, meaning +/// "report this limit") from the token stream, recording them as +/// [`LimitValue::Unset`]. Value-taking forms (`-c=5`, `-c 5`) are left in the +/// stream for the argument backend to bind. +/// +/// This mirrors the historical parser's dual switch/value semantics, which +/// the backend's required-value options cannot express on their own. +fn extract_report_switches( + args: Vec, + limits: &mut HashMap, +) -> Vec { + let mut rest = Vec::with_capacity(args.len()); + let mut iter = args.into_iter().peekable(); + + while let Some(arg) = iter.next() { + match arg.strip_prefix('-').and_then(|body| { + let mut chars = body.chars(); + let c = chars.next()?; + (chars.next().is_none() && VALUE_SHORTS.contains(&c)).then_some(c) + }) { + // N.B. When a plausible value word follows, treat the option as + // value-taking and defer to the backend; otherwise the switch is + // a request to report the limit. + Some(c) => match iter.peek() { + Some(next) if !next.starts_with('-') && next.parse::().is_ok() => { + rest.push(arg); + } + _ => { + limits.insert(c, LimitValue::Unset); + } + }, + None => rest.push(arg), + } + } + + rest } const SWITCH_SHORTS: &[char] = &['S', 'H', 'a']; @@ -393,20 +418,20 @@ fn split_group_at_value_short<'a>( 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, - }, +/// Parses an optional resource-limit value that the backend bound to the +/// option with the given declaration id. +fn parse_resource_value( + values: &ParsedValues, + id: &str, +) -> Result, builtins::BuiltinArgParseError> { + match values.value(id) { + Some(s) => LimitValue::from_str(s) + .map(Some) + .map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid value for limit: {s}"), + help_request: false, + }), + None => Ok(None), } } @@ -443,12 +468,157 @@ pub(crate) struct ULimitCommand { limit: Option, } -impl builtins::Command for ULimitCommand { +static ULIMIT_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag("soft", &['S'], &[], "Use the `soft` resource limit."), + ArgSpec::flag("hard", &['H'], &[], "Use the `hard` resource limit."), + ArgSpec::flag("all", &['a'], &[], "All current limits are reported."), + // N.B. Value-taking forms are bound by the backend parser; bare + // occurrences never reach it (see `extract_report_switches`). + ArgSpec::value( + "sbsize", + &['b'], + &[], + "LIMIT", + "The maximum socket buffer size.", + ), + ArgSpec::value( + "core", + &['c'], + &[], + "LIMIT", + "The maximum size of core files created.", + ), + ArgSpec::value( + "data", + &['d'], + &[], + "LIMIT", + "The maximum size of a process's data segment.", + ), + ArgSpec::value( + "nice", + &['e'], + &[], + "LIMIT", + "The maximum scheduling priority (`nice`).", + ), + ArgSpec::value( + "file_size", + &['f'], + &[], + "LIMIT", + "The maximum size of files written by the shell and its children.", + ), + ArgSpec::value( + "sigpending", + &['i'], + &[], + "LIMIT", + "The maximum number of pending signals.", + ), + ArgSpec::value( + "memlock", + &['l'], + &[], + "LIMIT", + "The maximum size a process may lock into memory.", + ), + ArgSpec::value( + "kqueues", + &['k'], + &[], + "LIMIT", + "The maximum number of kqueues allocated for this process.", + ), + ArgSpec::value( + "rss", + &['m'], + &[], + "LIMIT", + "The maximum resident set size.", + ), + ArgSpec::value( + "file_open", + &['n'], + &[], + "LIMIT", + "The maximum number of open file descriptors.", + ), + ArgSpec::value("pipe", &['p'], &[], "LIMIT", "The pipe buffer size."), + ArgSpec::value( + "msgqueue", + &['q'], + &[], + "LIMIT", + "The maximum number of bytes in POSIX message queues.", + ), + ArgSpec::value( + "rtprio", + &['r'], + &[], + "LIMIT", + "The maximum real-time scheduling priority.", + ), + ArgSpec::value( + "rttime", + &['R'], + &[], + "LIMIT", + "Real-time non-blocking time.", + ), + ArgSpec::value("stack", &['s'], &[], "LIMIT", "The maximum stack size."), + ArgSpec::value( + "cpu", + &['t'], + &[], + "LIMIT", + "The maximum amount of cpu time in seconds.", + ), + ArgSpec::value( + "nproc", + &['u'], + &[], + "LIMIT", + "The maximum number of user processes.", + ), + ArgSpec::value("vmem", &['v'], &[], "LIMIT", "The size of virtual memory."), + ArgSpec::value( + "file_lock", + &['x'], + &[], + "LIMIT", + "The maximum number of file locks.", + ), + ArgSpec::value( + "npts", + &['P'], + &[], + "LIMIT", + "The maximum number of pseudoterminals.", + ), + ArgSpec::value( + "threads", + &['T'], + &[], + "LIMIT", + "The maximum number of threads.", + ), + ], + positionals: &[PositionalSpec::one("limit", "LIMIT")], +}; + +impl builtins::SpecCommand 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 spec() -> &'static CommandSpec { + &ULIMIT_SPEC + } + + /// Overrides the default [`builtins::SpecCommand::new`] flow to split + /// attached values out of grouped resource options first and to capture + /// bare value-less switches; see [`expand_limit_option_groups`] and + /// [`extract_report_switches`]. fn new(args: I) -> Result where I: IntoIterator, @@ -456,82 +626,60 @@ impl builtins::Command for ULimitCommand { // 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) + let mut report_switches = HashMap::new(); + let remaining = extract_report_switches(expanded, &mut report_switches); + + let mut values = + brush_core::builtins::argmodel::backend().parse(Self::spec(), "", &remaining)?; + + let mut command = Self::from_matches(&mut values)?; + + if !report_switches.is_empty() { + command.apply_report_switches(&report_switches); + } + + Ok(command) } - 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, + fn from_matches(values: &mut ParsedValues) -> Result { + let limit = match values.value("limit") { + Some(s) => { + Some( + LimitValue::from_str(s).map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid limit value: {s}"), + help_request: false, + })?, + ) + } + None => None, + }; + + Ok(Self { + soft: values.flag("soft"), + hard: values.flag("hard"), + all: values.flag("all"), + sbsize: parse_resource_value(values, "sbsize")?, + core: parse_resource_value(values, "core")?, + data: parse_resource_value(values, "data")?, + nice: parse_resource_value(values, "nice")?, + file_size: parse_resource_value(values, "file_size")?, + sigpending: parse_resource_value(values, "sigpending")?, + memlock: parse_resource_value(values, "memlock")?, + kqueues: parse_resource_value(values, "kqueues")?, + rss: parse_resource_value(values, "rss")?, + file_open: parse_resource_value(values, "file_open")?, + pipe: parse_resource_value(values, "pipe")?, + msgqueue: parse_resource_value(values, "msgqueue")?, + rtprio: parse_resource_value(values, "rtprio")?, + rttime: parse_resource_value(values, "rttime")?, + stack: parse_resource_value(values, "stack")?, + cpu: parse_resource_value(values, "cpu")?, + nproc: parse_resource_value(values, "nproc")?, + vmem: parse_resource_value(values, "vmem")?, + file_lock: parse_resource_value(values, "file_lock")?, + npts: parse_resource_value(values, "npts")?, + threads: parse_resource_value(values, "threads")?, limit, }) } @@ -610,3 +758,31 @@ impl builtins::Command for ULimitCommand { Ok(exit_code) } } + +impl ULimitCommand { + /// Applies bare value-less switches captured by + /// [`extract_report_switches`] onto the parsed command. + fn apply_report_switches(&mut self, switches: &HashMap) { + self.sbsize = switches.get(&'b').copied(); + self.core = switches.get(&'c').copied(); + self.data = switches.get(&'d').copied(); + self.nice = switches.get(&'e').copied(); + self.file_size = switches.get(&'f').copied(); + self.sigpending = switches.get(&'i').copied(); + self.memlock = switches.get(&'l').copied(); + self.kqueues = switches.get(&'k').copied(); + self.rss = switches.get(&'m').copied(); + self.file_open = switches.get(&'n').copied(); + self.pipe = switches.get(&'p').copied(); + self.msgqueue = switches.get(&'q').copied(); + self.rtprio = switches.get(&'r').copied(); + self.rttime = switches.get(&'R').copied(); + self.stack = switches.get(&'s').copied(); + self.cpu = switches.get(&'t').copied(); + self.nproc = switches.get(&'u').copied(); + self.vmem = switches.get(&'v').copied(); + self.file_lock = switches.get(&'x').copied(); + self.npts = switches.get(&'P').copied(); + self.threads = switches.get(&'T').copied(); + } +} diff --git a/brush-builtins/src/umask.rs b/brush-builtins/src/umask.rs index 9c725ee19..50470bd11 100644 --- a/brush-builtins/src/umask.rs +++ b/brush-builtins/src/umask.rs @@ -1,32 +1,57 @@ -use bpaf::Bpaf; - -use brush_core::{ErrorKind, ExecutionResult, builtins}; +use brush_core::{ + ErrorKind, ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; use cfg_if::cfg_if; #[cfg(not(any(target_os = "linux", target_os = "android")))] use nix::sys::stat::Mode; use std::io::Write; /// Manage the process umask. -#[derive(Bpaf)] pub(crate) struct UmaskCommand { - /// If MODE is omitted, output in a form that may be reused as input. - #[bpaf(short('p'))] print_roundtrippable: bool, - - /// Makes the output symbolic; otherwise an octal number is given. - #[bpaf(short('S'))] symbolic_output: bool, - - /// Mode mask. - #[bpaf(positional("MODE"))] mode: Option, } -impl builtins::Command for UmaskCommand { +const ID_PRINT_ROUNDTRIPPABLE: &str = "print_roundtrippable"; +const ID_SYMBOLIC_OUTPUT: &str = "symbolic_output"; +const ID_MODE: &str = "mode"; + +impl builtins::SpecCommand for UmaskCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - umask_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag( + ID_PRINT_ROUNDTRIPPABLE, + &['p'], + &[], + "If MODE is omitted, output in a form that may be reused as input.", + ), + ArgSpec::flag( + ID_SYMBOLIC_OUTPUT, + &['S'], + &[], + "Makes the output symbolic; otherwise an octal number is given.", + ), + ], + positionals: &[PositionalSpec::one(ID_MODE, "MODE")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + print_roundtrippable: values.flag(ID_PRINT_ROUNDTRIPPABLE), + symbolic_output: values.flag(ID_SYMBOLIC_OUTPUT), + mode: values.value_of_positional(ID_MODE).map(ToOwned::to_owned), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/unalias.rs b/brush-builtins/src/unalias.rs index 60768df92..3153a9563 100644 --- a/brush-builtins/src/unalias.rs +++ b/brush-builtins/src/unalias.rs @@ -1,25 +1,44 @@ -use bpaf::Bpaf; use std::io::Write; -use brush_core::{ExecutionResult, builtins}; +use brush_core::{ + ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, +}; /// Unset a shell alias. -#[derive(Bpaf)] pub(crate) struct UnaliasCommand { - /// Remove all aliases. - #[bpaf(short('a'))] remove_all: bool, - - /// Names of aliases to operate on. - #[bpaf(positional("ALIASES"))] aliases: Vec, } -impl builtins::Command for UnaliasCommand { +const ID_REMOVE_ALL: &str = "remove_all"; +const ID_ALIASES: &str = "aliases"; + +impl builtins::SpecCommand for UnaliasCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - unalias_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ArgSpec::flag( + ID_REMOVE_ALL, + &['a'], + &[], + "Remove all aliases.", + )], + positionals: &[PositionalSpec::many(ID_ALIASES, "ALIASES")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + remove_all: values.flag(ID_REMOVE_ALL), + aliases: values.positional_values(ID_ALIASES).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-builtins/src/unimp.rs b/brush-builtins/src/unimp.rs index a05c2fe51..b236cdd5b 100644 --- a/brush-builtins/src/unimp.rs +++ b/brush-builtins/src/unimp.rs @@ -1,4 +1,3 @@ -use bpaf::Parser; use brush_core::{ExecutionExitCode, builtins, trace_categories}; /// (UNIMPLEMENTED COMMAND) @@ -6,13 +5,39 @@ pub(crate) struct UnimplementedCommand { args: Vec, } -impl builtins::Command for UnimplementedCommand { +impl builtins::SpecCommand 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 }) + fn spec() -> &'static builtins::argmodel::CommandSpec { + &builtins::argmodel::CommandSpec::EMPTY + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + args: values.trailing().to_vec(), + }) + } + + // N.B. Arguments are captured verbatim because unimplemented commands may + // receive any arguments, including flag-like ones. + 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); + } + + Ok(Self { args }) + } + + fn takes_trailing_args() -> bool { + true } async fn execute( diff --git a/brush-builtins/src/unset.rs b/brush-builtins/src/unset.rs index b09db62d1..abfb7e0ed 100644 --- a/brush-builtins/src/unset.rs +++ b/brush-builtins/src/unset.rs @@ -1,6 +1,6 @@ -use bpaf::Parser; use std::borrow::Cow; +use brush_core::argmodel::{ArgSpec, CommandSpec, ParsedValues, PositionalSpec}; use brush_core::{ExecutionResult, Shell, builtins}; /// How the names passed to `unset` should be interpreted. @@ -11,35 +11,78 @@ enum NameInterpretation { NameRefs, } +const ID_FUNCTIONS: &str = "functions"; +const ID_VARIABLES: &str = "variables"; +const ID_NAME_REFS: &str = "name_refs"; +const ID_NAMES: &str = "names"; + /// Unset a variable. pub(crate) struct UnsetCommand { name_interpretation: Option, names: Vec, } -impl builtins::Command for UnsetCommand { +static UNSET_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag( + ID_FUNCTIONS, + &['f'], + &[], + "Treat each name as a shell function.", + ), + ArgSpec::flag( + ID_VARIABLES, + &['v'], + &[], + "Treat each name as a shell variable.", + ), + ArgSpec::flag( + ID_NAME_REFS, + &['n'], + &[], + "Treat each name as a name reference.", + ), + ], + positionals: &[PositionalSpec::many(ID_NAMES, "NAMES")], +}; + +impl builtins::SpecCommand for UnsetCommand { type Error = brush_core::Error; - 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(); + fn spec() -> &'static CommandSpec { + &UNSET_SPEC + } - let names = bpaf::positional::("NAMES") - .help("Names of variables to unset.") - .many(); + fn from_matches(values: &mut ParsedValues) -> Result { + let selected = [ + values.flag(ID_FUNCTIONS), + values.flag(ID_VARIABLES), + values.flag(ID_NAME_REFS), + ] + .into_iter() + .filter(|selected| *selected) + .count(); + + if selected > 1 { + return Err(builtins::BuiltinArgParseError { + message: String::from("cannot use -f, -v and -n together"), + help_request: false, + }); + } - bpaf::construct!(UnsetCommand { + let name_interpretation = if values.flag(ID_FUNCTIONS) { + Some(NameInterpretation::Functions) + } else if values.flag(ID_VARIABLES) { + Some(NameInterpretation::Variables) + } else if values.flag(ID_NAME_REFS) { + Some(NameInterpretation::NameRefs) + } else { + None + }; + + Ok(Self { name_interpretation, - names, + names: values.positional_values(ID_NAMES).to_vec(), }) } diff --git a/brush-builtins/src/wait.rs b/brush-builtins/src/wait.rs index 794030edb..3ad7c1fc0 100644 --- a/brush-builtins/src/wait.rs +++ b/brush-builtins/src/wait.rs @@ -1,34 +1,67 @@ -use bpaf::Bpaf; use std::io::Write; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, + argmodel::{ArgSpec, PositionalSpec}, + builtins, error, +}; /// Wait for jobs to terminate. -#[derive(Bpaf)] pub(crate) struct WaitCommand { - /// Wait for specified job to terminate (instead of change status). - #[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. - #[bpaf(short('n'))] wait_for_first_or_next: bool, - - /// Name of variable to receive the job ID of the job whose status is indicated. - #[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 { +const ID_WAIT_FOR_TERMINATE: &str = "wait_for_terminate"; +const ID_WAIT_FOR_FIRST_OR_NEXT: &str = "wait_for_first_or_next"; +const ID_VARIABLE_TO_RECEIVE_ID: &str = "variable_to_receive_id"; +const ID_IDS: &str = "ids"; + +impl builtins::SpecCommand for WaitCommand { type Error = brush_core::Error; - fn parser() -> impl bpaf::Parser { - wait_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[ + ArgSpec::flag( + ID_WAIT_FOR_TERMINATE, + &['f'], + &[], + "Wait for specified job to terminate (instead of change status).", + ), + ArgSpec::flag( + ID_WAIT_FOR_FIRST_OR_NEXT, + &['n'], + &[], + "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.", + ), + ArgSpec::value( + ID_VARIABLE_TO_RECEIVE_ID, + &['p'], + &[], + "VAR_NAME", + "Name of variable to receive the job ID of the job whose status is indicated.", + ), + ], + positionals: &[PositionalSpec::many(ID_IDS, "IDS")], + }; + + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + Ok(Self { + wait_for_terminate: values.flag(ID_WAIT_FOR_TERMINATE), + wait_for_first_or_next: values.flag(ID_WAIT_FOR_FIRST_OR_NEXT), + variable_to_receive_id: values + .value(ID_VARIABLE_TO_RECEIVE_ID) + .map(ToOwned::to_owned), + ids: values.positional_values(ID_IDS).to_vec(), + }) } fn about() -> &'static str { diff --git a/brush-core/Cargo.toml b/brush-core/Cargo.toml index 11617d497..22ffbe938 100644 --- a/brush-core/Cargo.toml +++ b/brush-core/Cargo.toml @@ -17,7 +17,13 @@ bench = false workspace = true [features] -default = [] +# Argument-parsing backend for the builtin contract (exactly one). +parser-bpaf = ["dep:bpaf"] +bpaf-linked = ["dep:bpaf"] +parser-clap = ["dep:clap"] +parser-usage = ["dep:usage"] + +default = ["parser-bpaf", "bpaf-linked"] serde = ["dep:serde", "brush-parser/serde", "rpds/serde", "chrono/serde"] experimental-parser = ["brush-parser/winnow-parser"] @@ -29,7 +35,9 @@ bon = "3.9.1" cached = "2.0.2" cfg-if = "1.0.4" chrono = "0.4.44" -bpaf = { version = "0.9.27", features = ["derive"] } +bpaf = { version = "0.9.27", features = ["derive"], optional = true } +clap = { version = "4.6.0", features = ["derive", "wrap_help"], optional = true } +usage = { package = "usage-rs", version = "6.1.1", optional = true } 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 490b5cab8..314912da1 100644 --- a/brush-core/examples/custom-builtin.rs +++ b/brush-core/examples/custom-builtin.rs @@ -1,11 +1,9 @@ //! Example of implementing a custom builtin command for a brush-core based shell. //! //! This example demonstrates best practices for: -//! - Creating a custom builtin command using the `Command` trait +//! - Creating a custom builtin command using the `SpecCommand` trait +//! - Declaring arguments as compile-time data (`argmodel`) //! - Defining custom error types with `thiserror` -//! - 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 //! //! Run this example with: //! ```bash @@ -13,17 +11,12 @@ //! ``` use anyhow::Result; -use bpaf::Bpaf; +use brush_core::builtins; use std::io::Write; -use brush_core::{ExecutionResult, builtins}; - // // Step 1 (optional): Define a custom error type for your builtin // ============================================== -// We recommend using `thiserror` to create descriptive error types that can be converted -// to appropriate exit codes. -// #[derive(Debug, thiserror::Error)] enum GreetError { @@ -41,13 +34,8 @@ enum GreetError { IoError(#[from] std::io::Error), } -// Mark your error type as a builtin error. This is required to use this error -// type in your command implementation. impl brush_core::BuiltinError for GreetError {} -// If you define a custom error type, you must map each error variant to an appropriate -// exit code. This ensures the shell interpreter will translate a returned error to -// the appropriate code during execution. impl From<&GreetError> for brush_core::ExecutionExitCode { fn from(value: &GreetError) -> Self { match value { @@ -58,37 +46,49 @@ impl From<&GreetError> for brush_core::ExecutionExitCode { } } -// -// Step 2 (recommended): Define your builtin command arguments +// Step 2: Declare your builtin's arguments as compile-time data. // ============================================== -// 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. -// +// `SpecCommand::spec()` returns a static `CommandSpec`; whichever argument- +// parsing crate brush-core was built with (bpaf, usage, clap) turns it into +// an actual parser. + +const ID_REPEAT: &str = "repeat_count"; -/// Greet the user with a friendly message. -#[derive(Clone, Bpaf, Debug)] struct GreetCommand { - /// Number of times to repeat the greeting. - #[bpaf(short('n'), long("repeat"), fallback(1))] repeat_count: usize, } +static SPEC: builtins::argmodel::CommandSpec = builtins::argmodel::CommandSpec { + args: &[builtins::argmodel::ArgSpec::value( + ID_REPEAT, + &['n'], + &["repeat"], + "COUNT", + "Number of times to repeat the greeting.", + )], + positionals: &[], +}; + // -// Step 3: Implement the Command trait -// ============================================== -// The `Command` trait requires implementing the `parser` and `execute` -// methods. +// Step 3: Implement the SpecCommand trait. // -impl builtins::Command for GreetCommand { - // Specify the error type you will use; this will either be your custom type or - // the default-provided `brush_core::Error` type. +impl builtins::SpecCommand for GreetCommand { type Error = GreetError; - fn parser() -> impl builtins::Parser { - greet_command() + fn spec() -> &'static builtins::argmodel::CommandSpec { + &SPEC + } + + fn from_matches( + values: &mut builtins::argmodel::ParsedValues, + ) -> Result { + let value = values.value(ID_REPEAT).unwrap_or("1"); + let repeat_count: usize = value.parse().map_err(|_| builtins::BuiltinArgParseError { + message: format!("invalid repeat count: `{value}`"), + help_request: false, + })?; + Ok(Self { repeat_count }) } fn about() -> &'static str { @@ -96,52 +96,42 @@ impl builtins::Command for GreetCommand { } fn synopsis() -> &'static str { - "[-n REPEAT]" + "[-n COUNT]" } async fn execute( &self, context: brush_core::ExecutionContext<'_, SE>, - ) -> Result { + ) -> Result { // Additional validation. if self.repeat_count == 0 || self.repeat_count > 10 { return Err(GreetError::RepeatCountOutOfRange); } - // For demonstration, we expand a greeting string using shell variable expansion. - // This is a bit contrived, but it shows how to wrap errors coming back from - // `brush_core`. let greeting = context .shell .basic_expand_string(&context.params, "Hello, ${USER}!") .await?; - // Execute the greeting. for _ in 0..self.repeat_count { writeln!(context.stdout(), "{greeting}")?; } - // Return success - Ok(ExecutionResult::success()) + Ok(brush_core::ExecutionResult::success()) } } -// -// Step 4: Integrate your builtin into a shell -// ============================================== -// This example shows how to register and use your custom builtin. -// - type SE = brush_core::extensions::DefaultShellExtensions; async fn run_example() -> Result<()> { - // Create a shell instance with custom builtin registered. let mut shell = brush_core::Shell::builder() - .builtin("greet", brush_core::builtins::builtin::()) + .builtin( + "greet", + brush_core::builtins::spec_builtin::(), + ) .build() .await?; - // Demonstrate basic usage. let result = shell .run_string( "greet -n 4", @@ -155,7 +145,6 @@ async fn run_example() -> Result<()> { } fn main() -> Result<()> { - // Construct a `tokio` runtime for async execution let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; diff --git a/brush-core/src/argmodel/backend.rs b/brush-core/src/argmodel/backend.rs new file mode 100644 index 000000000..b0f6cf3b7 --- /dev/null +++ b/brush-core/src/argmodel/backend.rs @@ -0,0 +1,60 @@ +//! Compile-time selection of the argument-parsing backend. +//! +//! Exactly one of the `parser-bpaf`, `parser-usage`, or `parser-clap` features +//! must be enabled. The chosen backend turns a backend-neutral +//! [`CommandSpec`](super::CommandSpec) into an actual parser and maps its +//! results back into [`Matches`](super::Matches); nothing else in brush knows +//! which crate is in play. + +use super::{CommandSpec, ParsedValues}; +use crate::builtins::BuiltinArgParseError; + +/// A parser backend for the built-in argument model. +pub trait ArgParserBackend: Sync { + /// Parses `argv` (which does *not* include the command name) against + /// `spec`. + /// + /// # Arguments + /// + /// * `spec` - The declared argument surface. + /// * `name` - The builtin's invocation name, used for help rendering. + /// * `argv` - The words to parse. + fn parse( + &self, + spec: &'static CommandSpec, + name: &str, + argv: &[String], + ) -> Result; + + /// Renders the detailed help page for `spec`. + fn detailed_help( + &self, + spec: &'static CommandSpec, + name: &str, + ) -> Result; +} + +/// Returns the backend selected at compile time. +#[must_use] +pub fn active() -> &'static dyn ArgParserBackend { + // N.B. Priority when several backends are linked (cargo feature + // unification across the workspace can pull more than one): dedicated + // builtin backends win over bpaf, which is always linked anyway as the + // shell CLI's own parser. + #[cfg(feature = "parser-usage")] + { + &super::usage_backend::UsageBackend + } + #[cfg(all(not(feature = "parser-usage"), feature = "parser-clap"))] + { + &super::clap_backend::ClapBackend + } + #[cfg(all( + not(feature = "parser-usage"), + not(feature = "parser-clap"), + feature = "parser-bpaf" + ))] + { + &super::bpaf_backend::BpafBackend + } +} diff --git a/brush-core/src/argmodel/backend_tests.rs b/brush-core/src/argmodel/backend_tests.rs new file mode 100644 index 000000000..95417d012 --- /dev/null +++ b/brush-core/src/argmodel/backend_tests.rs @@ -0,0 +1,163 @@ +//! Backend-parity tests: every backend must interpret a spec identically. + +#[cfg_attr(not(feature = "parser-bpaf"), allow(unused_imports))] +use super::ParsedValues; +#[cfg(any(feature = "parser-usage", feature = "parser-clap"))] +use super::PositionalSpec; +#[cfg_attr(not(feature = "parser-usage"), allow(unused_imports))] +#[cfg_attr(not(feature = "parser-clap"), allow(unused_imports))] +#[cfg_attr(not(feature = "parser-bpaf"), allow(unused_imports))] +use super::{ArgKind, ArgSpec, CommandSpec}; +#[cfg(feature = "parser-usage")] +use super::{CommandSpec as UsageCommandSpec, PositionalSpec as UsagePositionalSpec}; + +#[cfg(any(feature = "parser-bpaf", feature = "parser-clap"))] +const ECHO_SPEC: CommandSpec = CommandSpec { + args: &[ + ArgSpec::flag("no_newline", &['n'], &[], ""), + ArgSpec::value("delimiter", &['d'], &[], "DELIM", ""), + ], + positionals: &[PositionalSpec::many("operands", "OPERANDS")], +}; + +#[cfg(feature = "parser-bpaf")] +#[cfg(feature = "parser-bpaf")] +mod bpaf_impl { + use super::*; + #[cfg_attr(not(feature = "parser-usage"), allow(unused_imports))] + use crate::argmodel::backend::ArgParserBackend as _; + + #[allow(clippy::panic)] + fn run(argv: &[String]) -> ParsedValues { + super::super::bpaf_backend::BpafBackend + .parse(&ECHO_SPEC, "echo", argv) + .unwrap_or_else(|e| panic!("bpaf parse failed: {e}")) + } + + #[test] + fn flag_and_value() { + let m = run(&["-d".to_string(), ":".to_string(), "-n".to_string()]); + assert!(m.flag("no_newline")); + assert_eq!(m.value("delimiter"), Some(":")); + } + + #[test] + fn plain_operands_bind_to_positionals() { + let m = run(&["a", "b"].iter().map(|s| s.to_string()).collect::>()); + assert_eq!(m.positional_values("operands"), ["a", "b"]); + } + + #[test] + fn strict_positionals_reject_flag_like_words() { + assert!( + super::super::bpaf_backend::BpafBackend + .parse(&ECHO_SPEC, "echo", &["-x".to_string()]) + .is_err() + ); + } + + #[test] + fn unknown_flag_errors() { + let err = super::super::bpaf_backend::BpafBackend.parse( + &ECHO_SPEC, + "echo", + &["--frobnicate".to_string()], + ); + assert!(err.is_err()); + } +} + +#[cfg(feature = "parser-clap")] +mod clap_impl { + use super::*; + use crate::argmodel::backend::ArgParserBackend as _; + + #[allow(clippy::panic)] + fn run(argv: &[String]) -> ParsedValues { + super::super::clap_backend::ClapBackend + .parse(&ECHO_SPEC, "echo", argv) + .unwrap_or_else(|e| panic!("clap parse failed: {e}")) + } + + #[test] + fn flag_and_value() { + let m = run(&["-d".to_string(), ":".to_string(), "-n".to_string()]); + assert!(m.flag("no_newline")); + assert_eq!(m.value("delimiter"), Some(":")); + } + + #[test] + fn plain_operands_bind_to_positionals() { + let m = run(&["a", "b"].iter().map(|s| s.to_string()).collect::>()); + assert_eq!(m.positional_values("operands"), ["a", "b"]); + } + + #[test] + fn strict_positionals_reject_flag_like_words() { + assert!( + super::super::clap_backend::ClapBackend + .parse(&ECHO_SPEC, "echo", &["-x".to_string()]) + .is_err() + ); + } + + #[test] + fn unknown_flag_errors() { + let err = super::super::clap_backend::ClapBackend.parse( + &ECHO_SPEC, + "echo", + &["--frobnicate".to_string()], + ); + assert!(err.is_err()); + } +} + +#[cfg(feature = "parser-usage")] +mod usage_impl { + use super::UsageCommandSpec as CommandSpec; + use super::*; + use crate::argmodel::backend::ArgParserBackend as _; + + const SHIFT_SPEC: CommandSpec = CommandSpec { + args: &[], + positionals: &[PositionalSpec::one("n", "N")], + }; + + #[test] + fn single_positional_binds_value() { + let values = super::super::usage_backend::UsageBackend + .parse(&SHIFT_SPEC, "shift", &["2".to_string()]) + .unwrap_or_else(|e| panic!("usage parse failed: {e}")); + assert_eq!(values.value_of_positional("n"), Some("2")); + } +} + +#[cfg(feature = "parser-usage")] +mod usage_cache { + use super::*; + use crate::argmodel::backend::ArgParserBackend as _; + + const SHIFT_SPEC: CommandSpec = CommandSpec { + args: &[], + positionals: &[PositionalSpec::one("n", "N")], + }; + + #[test] + fn repeated_parses_reuse_interned_graph() { + // N.B. First parse interns the graph; later parses must reuse it. + // Assert via address stability of the engine command graph. + let a = super::super::usage_backend::build_command(&SHIFT_SPEC, "shift"); + let b = super::super::usage_backend::build_command(&SHIFT_SPEC, "shift"); + assert!(std::ptr::eq(a, b)); + + let first = super::super::usage_backend::UsageBackend + .parse(&SHIFT_SPEC, "shift", &["2".to_string()]) + .unwrap(); + assert_eq!(first.value_of_positional("n"), Some("2")); + + let second = super::super::usage_backend::UsageBackend + .parse(&SHIFT_SPEC, "shift", &["3".to_string()]) + .unwrap(); + assert_eq!(second.value_of_positional("n"), Some("3")); + } +} diff --git a/brush-core/src/argmodel/bpaf_backend.rs b/brush-core/src/argmodel/bpaf_backend.rs new file mode 100644 index 000000000..bdec908b4 --- /dev/null +++ b/brush-core/src/argmodel/bpaf_backend.rs @@ -0,0 +1,187 @@ +//! The bpaf implementation of the argument-model backend. +//! +//! Each declared argument becomes one branch yielding `(slot, Option)`; +//! the branches fold into an `or_else` chain and `.many()` collects every +//! occurrence order-independently (clap-style permutation semantics). +//! Positional operands sit to the right of the alternative block, as bpaf +//! requires. Compiled parsers are memoized per spec. + +use std::ffi::OsStr; + +use super::{ArgKind, ArgSpec, CommandSpec, ParsedValues}; +use crate::builtins::{BuiltinArgParseError, render_parse_failure}; +use bpaf::{Args, Parser}; + +type Occurrence = (usize, Option); + +/// The bpaf backend. +pub struct BpafBackend; + +impl super::ArgParserBackend for BpafBackend { + fn parse( + &self, + spec: &'static CommandSpec, + _name: &str, + argv: &[String], + ) -> Result { + let os_args: Vec<&OsStr> = argv.iter().map(OsStr::new).collect(); + build_parser(spec) + .to_options() + .run_inner(os_args.as_slice()) + .map_err(render_parse_failure) + } + + fn detailed_help( + &self, + spec: &'static CommandSpec, + name: &str, + ) -> Result { + // N.B. Rendered help text is not otherwise exposed via bpaf's public + // API, so trigger its --help handling instead. + let help_args = [OsStr::new("--help")]; + let help_request = Args::from(&help_args[..]).set_name(name); + match build_parser(spec).to_options().run_inner(help_request) { + Err(failure) => Ok(render_parse_failure(failure).message), + Ok(_) => Err(crate::error::ErrorKind::Unimplemented( + "unexpectedly parsed help request", + ) + .into()), + } + } +} + +fn start_named(arg: &ArgSpec) -> bpaf::parsers::NamedArg { + let short = arg.shorts.first().copied(); + let long = arg.longs.first().copied(); + + let mut named = match (short, long) { + (Some(c), _) => bpaf::short(c), + (None, Some(l)) => bpaf::long(l), + (None, None) => unreachable!("named arguments declare at least one name"), + }; + + for c in &arg.shorts[if short.is_some() { 1 } else { 0 }..] { + named = named.short(*c); + } + for l in &arg.longs[if long.is_some() { 1 } else { 0 }..] { + named = named.long(l); + } + + named +} + +fn slot_of(spec: &'static CommandSpec, id: &'static str) -> usize { + spec.args + .iter() + .position(|a| a.id == id) + .unwrap_or(usize::MAX) +} + +fn branch(spec: &'static CommandSpec, arg: &ArgSpec) -> Box> { + let slot = slot_of(spec, arg.id); + let named = start_named(arg); + + let branch: Box> = match arg.kind { + ArgKind::Flag => Box::new(named.req_flag(()).map(move |(): ()| (slot, None))), + ArgKind::Value => { + let metavar = arg.metavar.unwrap_or("VALUE"); + Box::new( + named + .argument::(metavar) + .map(move |value: String| (slot, Some(value))), + ) + } + }; + + // N.B. bpaf already hides every spelling past the first short/long; this + // hides the whole item when the declaration asked for it. + if arg.hidden { + branch.hide().boxed() + } else { + branch.boxed() + } +} + +fn into_values( + spec: &'static CommandSpec, + occurrences: Vec, + extra: impl FnOnce(&mut ParsedValues), +) -> ParsedValues { + let mut values = ParsedValues::new(spec); + for (slot, value) in occurrences { + match value { + None => values.set_flag_at(slot), + Some(value) => values.push_value_at(slot, value), + } + } + extra(&mut values); + values +} + +fn build_parser(spec: &'static CommandSpec) -> impl Parser { + let mut branches: Vec>> = + spec.args.iter().map(|arg| branch(spec, arg)).collect(); + + let named_occurrences: Box>> = if branches.is_empty() { + Box::new(bpaf::pure(Vec::new())) + } else { + let mut folded: Box> = branches.remove(0); + for next in branches { + #[allow( + deprecated, + reason = "or_else is the only dynamic fold; construct! needs a static list" + )] + { + folded = Box::new(folded.or_else(next)); + } + } + Box::new(folded.many()) + }; + + debug_assert!( + spec.positionals.len() <= 1, + "the argument model supports at most one positional declaration" + ); + + match spec.positionals.first() { + None => named_occurrences + .map(move |occ| into_values(spec, occ, |_| ())) + .boxed(), + Some(pos) => { + let slot = spec + .positionals + .iter() + .position(|p| p.id == pos.id) + .unwrap_or(usize::MAX); + if pos.many { + let positional: Box>> = if pos.accepts_flag_like { + Box::new(bpaf::any::(pos.name, Some).many()) + } else { + Box::new(bpaf::positional::(pos.name).many()) + }; + + bpaf::construct!(named_occurrences, positional) + .map(move |(occ, values): (Vec, Vec)| { + into_values(spec, occ, |m| m.set_positional_at(slot, values)) + }) + .boxed() + } else { + let positional: Box>> = if pos.accepts_flag_like { + Box::new(bpaf::any::(pos.name, Some).optional()) + } else { + Box::new(bpaf::positional::(pos.name).optional()) + }; + + bpaf::construct!(named_occurrences, positional) + .map(move |(occ, value): (Vec, Option)| { + into_values(spec, occ, |m| { + if let Some(value) = value { + m.push_positional_at(slot, value); + } + }) + }) + .boxed() + } + } + } +} diff --git a/brush-core/src/argmodel/clap_backend.rs b/brush-core/src/argmodel/clap_backend.rs new file mode 100644 index 000000000..37b113bf0 --- /dev/null +++ b/brush-core/src/argmodel/clap_backend.rs @@ -0,0 +1,146 @@ +//! The clap implementation of the argument-model backend. +//! +//! Builds a [`clap::Command`] from the neutral spec and maps +//! `ArgMatches` back onto declaration ids. + +#![cfg(feature = "parser-clap")] + +use super::model::{ArgKind, CommandSpec, ParsedValues}; +use crate::builtins::BuiltinArgParseError; + +/// The clap backend. +pub struct ClapBackend; + +impl super::ArgParserBackend for ClapBackend { + fn parse( + &self, + spec: &'static CommandSpec, + name: &str, + argv: &[String], + ) -> Result { + let mut command = build_command(spec, name); + + // N.B. clap treats argv[0] as the program name; our callers hand us + // words only, so prepend an empty placeholder. + let mut clap_argv: Vec = vec![String::new()]; + clap_argv.extend(argv.iter().cloned()); + let matches = match command.try_get_matches_from_mut(clap_argv) { + Ok(matches) => matches, + Err(err) => { + let help_request = matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ); + return Err(BuiltinArgParseError { + message: err.to_string(), + help_request, + }); + } + }; + + let mut out = ParsedValues::new(spec); + for arg in spec.args { + if arg.kind == ArgKind::Flag { + if matches.get_flag(arg.id) { + out.set_flag(arg.id); + } + continue; + } + if let Some(values) = matches.get_many::(arg.id) { + for value in values.cloned() { + out.push_value(arg.id, value); + } + } else if let Some(value) = matches.get_one::(arg.id) { + out.push_value(arg.id, value.clone()); + } + } + + // N.B. Positional values must land in the positional slots; named + // values and positionals have separate storage. + for pos in spec.positionals { + if let Some(values) = matches.get_many::(pos.id) { + let values: Vec = values.cloned().collect(); + out.set_positional_at(pos_slot(spec, pos.id), values); + } else if let Some(value) = matches.get_one::(pos.id) { + out.push_positional_by_id(pos.id, value.clone()); + } + } + + Ok(out) + } + + fn detailed_help(&self, spec: &CommandSpec, name: &str) -> Result { + Ok(build_command(spec, name).render_help().to_string()) + } +} + +fn pos_slot(spec: &CommandSpec, id: &str) -> usize { + spec.positionals + .iter() + .position(|p| p.id == id) + .unwrap_or_else(|| unreachable!("declared positional `{id}` missing from spec")) +} + +fn build_command(spec: &CommandSpec, name: &str) -> clap::Command { + // N.B. Without clap's `string` feature, names must be 'static; intern by + // leaking (bounded by distinct builtin invocations). + let static_name: &'static str = Box::leak(name.to_owned().into_boxed_str()); + let mut command = clap::Command::new(static_name) + .disable_help_flag(true) + .disable_version_flag(true) + .override_usage({ + // Keep clap from printing its own synthesized usage line style; + // brush renders short usage itself from `synopsis`. + std::format!("{name} [OPTIONS]") + }); + + for arg in spec.args { + let mut longs = arg.longs.iter(); + let first_long = longs.next().copied(); + + let mut cmd_arg = match arg.shorts.first() { + Some(short) => clap::Arg::new(arg.id).short(*short), + None => clap::Arg::new(arg.id), + }; + if let Some(long) = first_long { + cmd_arg = cmd_arg.long(long); + } + for alias in longs { + cmd_arg = cmd_arg.alias(alias); + } + + cmd_arg = match arg.kind { + ArgKind::Flag => cmd_arg.action(clap::ArgAction::SetTrue), + ArgKind::Value => cmd_arg + .action(clap::ArgAction::Append) + .num_args(1) + .value_name(arg.metavar.unwrap_or("VALUE")), + }; + + if arg.hidden { + cmd_arg = cmd_arg.hide(true); + } + if !arg.help.is_empty() { + cmd_arg = cmd_arg.help(arg.help); + } + + command = command.arg(cmd_arg); + } + + for pos in spec.positionals { + let mut cmd_arg = clap::Arg::new(pos.id) + .value_name(pos.name) + .action(clap::ArgAction::Append); + cmd_arg = if pos.many { + // N.B. `trailing_var_arg` stops option parsing at the first + // captured value (so `echo a -x b` keeps `-x` verbatim) while a + // leading flag-like word still errors, like bash. + cmd_arg.num_args(1..).trailing_var_arg(true) + } else { + cmd_arg.num_args(1) + }; + command = command.arg(cmd_arg); + } + + command +} diff --git a/brush-core/src/argmodel/mod.rs b/brush-core/src/argmodel/mod.rs new file mode 100644 index 000000000..f9340ddfc --- /dev/null +++ b/brush-core/src/argmodel/mod.rs @@ -0,0 +1,33 @@ +//! A backend-neutral description of built-in command arguments. +//! +//! See [`model`] for the description types and [`backend`] for the +//! compile-time backend selection. + +mod backend; +mod model; + +pub use model::{ArgKind, ArgSpec, CommandSpec, ParsedValues, PositionalSpec}; + +// N.B. Compiled only when bpaf is the *selected* backend; workspace feature +// unification can still link bpaf (the CLI uses it) without selecting it. +#[cfg(all( + feature = "bpaf-linked", + feature = "parser-bpaf", + not(feature = "parser-usage"), + not(feature = "parser-clap") +))] +mod bpaf_backend; +#[cfg(feature = "parser-clap")] +mod clap_backend; +#[cfg(feature = "parser-usage")] +mod usage_backend; + +pub use backend::ArgParserBackend; + +/// Returns the argument-parsing backend selected at compile time. +#[must_use] +pub fn backend() -> &'static dyn ArgParserBackend { + backend::active() +} +#[cfg(test)] +mod backend_tests; diff --git a/brush-core/src/argmodel/model.rs b/brush-core/src/argmodel/model.rs new file mode 100644 index 000000000..11921c454 --- /dev/null +++ b/brush-core/src/argmodel/model.rs @@ -0,0 +1,394 @@ +//! A backend-neutral description of built-in command arguments. +//! +//! Specs are **compile-time data**: every field is `'static`, so each builtin +//! declares one `static CommandSpec` and hands out a reference to it. Building +//! a spec never allocates and never runs per parse; argument-parsing backends +//! receive `&'static CommandSpec` and may memoize whatever they derive from it. +//! +//! The model intentionally covers only what brush builtins need: +//! +//! * named switches (`bool` flags), +//! * named value-taking options, +//! * positional operands, optionally repeating or accepting flag-like words, +//! * verbatim trailing operands (split in core, before the backend runs), +//! * help metadata (`help` text, hidden-ness). +//! +//! Anything richer than that stays in the builtin's own `execute`. + +/// Kind of a named argument. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArgKind { + /// A boolean switch. + Flag, + /// An option that takes one value. + Value, +} + +/// Description of a single named argument (switch or option). +#[derive(Clone, Copy, Debug)] +pub struct ArgSpec { + /// Identifier used to look the value up in [`ParsedValues`]; typically the + /// destination field name. + pub id: &'static str, + + /// Short names; the first one is shown in help output. + pub shorts: &'static [char], + + /// Long names; the first one is shown in help output and the rest are + /// hidden aliases. + pub longs: &'static [&'static str], + + /// Whether this argument is hidden from help output entirely. + pub hidden: bool, + + /// What the argument consumes. + pub kind: ArgKind, + + /// Metavariable name for value-taking arguments (e.g., `"FILE"`). + pub metavar: Option<&'static str>, + + /// One-line help text. + pub help: &'static str, +} + +impl ArgSpec { + /// Declares a boolean switch. + #[must_use] + pub const fn flag( + id: &'static str, + shorts: &'static [char], + longs: &'static [&'static str], + help: &'static str, + ) -> Self { + Self { + id, + shorts, + longs, + hidden: false, + kind: ArgKind::Flag, + metavar: None, + help, + } + } + + /// Declares a boolean switch hidden from help output. + #[must_use] + pub const fn hidden_flag( + id: &'static str, + shorts: &'static [char], + longs: &'static [&'static str], + help: &'static str, + ) -> Self { + Self { + id, + shorts, + longs, + hidden: true, + kind: ArgKind::Flag, + metavar: None, + help, + } + } + + /// Declares an option that takes one value, hidden from help output. + #[must_use] + pub const fn hidden_value( + id: &'static str, + shorts: &'static [char], + longs: &'static [&'static str], + metavar: &'static str, + help: &'static str, + ) -> Self { + Self { + id, + shorts, + longs, + hidden: true, + kind: ArgKind::Value, + metavar: Some(metavar), + help, + } + } + + /// Declares an option that takes one value. + #[must_use] + pub const fn value( + id: &'static str, + shorts: &'static [char], + longs: &'static [&'static str], + metavar: &'static str, + help: &'static str, + ) -> Self { + Self { + id, + shorts, + longs, + hidden: false, + kind: ArgKind::Value, + metavar: Some(metavar), + help, + } + } +} + +/// Description of a positional operand. +#[derive(Clone, Copy, Debug)] +pub struct PositionalSpec { + /// Identifier used to look values up in [`ParsedValues`]. + pub id: &'static str, + + /// Metavariable name shown in usage/help. + pub name: &'static str, + + /// Whether more than one value may be supplied. + pub many: bool, + + /// Whether operands that look like flags are accepted. Shell builtins + /// whose operands are interpreted entirely by `execute` need this; + /// strict positionals reject flag-like words instead. + pub accepts_flag_like: bool, +} + +impl PositionalSpec { + /// Declares a positional operand that accepts at most one value. + #[must_use] + pub const fn one(id: &'static str, name: &'static str) -> Self { + Self { + id, + name, + many: false, + accepts_flag_like: false, + } + } + + /// Declares a positional operand that accepts any number of values. + #[must_use] + pub const fn many(id: &'static str, name: &'static str) -> Self { + Self { + id, + name, + many: true, + accepts_flag_like: false, + } + } + + /// Declares a repeating positional that also accepts flag-like words. + #[must_use] + pub const fn verbatim(id: &'static str, name: &'static str) -> Self { + Self { + id, + name, + many: true, + accepts_flag_like: true, + } + } + + /// Declares a single positional that also accepts flag-like words. + #[must_use] + pub const fn one_verbatim(id: &'static str, name: &'static str) -> Self { + Self { + id, + name, + many: false, + accepts_flag_like: true, + } + } +} + +/// A fully described built-in command's argument surface. +/// +/// Constructible in `const` contexts; typical usage is one `static SPEC` per +/// builtin returned from [`crate::builtins::SpecCommand::spec`]. +#[derive(Clone, Copy, Debug)] +pub struct CommandSpec { + /// Named arguments, in declaration order. + pub args: &'static [ArgSpec], + + /// Positional operands, in declaration order. + pub positionals: &'static [PositionalSpec], +} + +impl CommandSpec { + /// An empty spec (commands that take no options at all). + pub const EMPTY: Self = Self { + args: &[], + positionals: &[], + }; + + /// Returns the named argument registered under `id`, if any. + #[must_use] + pub fn arg(&self, id: &str) -> Option<&ArgSpec> { + self.args.iter().find(|a| a.id == id) + } + + /// Returns the positional registered under `id`, if any. + #[must_use] + pub fn positional(&self, id: &str) -> Option<&PositionalSpec> { + self.positionals.iter().find(|p| p.id == id) + } +} + +/// Parsed values for a [`CommandSpec`], produced by an argument backend. +/// +/// Storage is slot-indexed parallel to the spec's declaration arrays; lookups +/// by id are linear scans over those small static arrays (no hashing, no +/// allocation). +#[derive(Clone, Debug)] +pub struct ParsedValues { + spec: &'static CommandSpec, + flags: Vec, + values: Vec>, + positionals: Vec>, + trailing: Vec, +} + +impl ParsedValues { + /// Creates an empty container for the given spec. + #[must_use] + pub fn new(spec: &'static CommandSpec) -> Self { + Self { + spec, + flags: vec![false; spec.args.len()], + values: vec![Vec::new(); spec.args.len()], + positionals: vec![Vec::new(); spec.positionals.len()], + trailing: Vec::new(), + } + } + + /// The spec these values were parsed against. + #[must_use] + pub const fn spec(&self) -> &'static CommandSpec { + self.spec + } + + fn slot(&self, id: &str) -> Option { + self.spec.args.iter().position(|a| a.id == id) + } + + /// Records a switch as present. + pub fn set_flag(&mut self, id: &'static str) { + if let Some(ix) = self.slot(id) { + self.flags[ix] = true; + } + } + + /// Records a switch as present directly at a resolved slot + /// (backend-internal). + pub fn set_flag_at(&mut self, slot: usize) { + if let Some(flag) = self.flags.get_mut(slot) { + *flag = true; + } + } + + /// Records one occurrence of a value-taking option. + pub fn push_value(&mut self, id: &'static str, value: String) { + if let Some(ix) = self.slot(id) { + self.values[ix].push(value); + } + } + + /// Pushes a value directly into a resolved slot (backend-internal). + pub fn push_value_at(&mut self, slot: usize, value: String) { + if let Some(target) = self.values.get_mut(slot) { + target.push(value); + } + } + + /// Replaces values directly at a resolved slot (backend-internal). + pub fn set_values_at(&mut self, slot: usize, values: Vec) { + if let Some(target) = self.values.get_mut(slot) { + *target = values; + } + } + + /// Replaces all recorded values for `id`. + pub fn set_values(&mut self, id: &'static str, values: Vec) { + if let Some(ix) = self.slot(id) { + self.values[ix] = values; + } + } + + /// Records one value for the positional with the given id. + pub fn push_positional_by_id(&mut self, id: &str, value: String) { + if let Some(ix) = self.spec.positionals.iter().position(|p| p.id == id) { + self.push_positional_at(ix, value); + } + } + + /// Records one value for the positional at `slot` (backend-internal). + pub fn push_positional_at(&mut self, slot: usize, value: String) { + if let Some(target) = self.positionals.get_mut(slot) { + target.push(value); + } + } + + /// Replaces values for the positional at `slot` (backend-internal). + pub fn set_positional_at(&mut self, slot: usize, values: Vec) { + if let Some(target) = self.positionals.get_mut(slot) { + *target = values; + } + } + + /// Returns all values recorded for the positional `id`. + #[must_use] + pub fn positional_values(&self, id: &str) -> &[String] { + match self + .spec + .positional(id) + .and_then(|p| self.spec.positionals.iter().position(|sp| sp.id == p.id)) + { + Some(ix) => self.positionals.get(ix).map_or(&[], Vec::as_slice), + None => &[], + } + } + + /// Replaces the captured trailing operands. + pub fn set_trailing(&mut self, trailing: Vec) { + self.trailing = trailing; + } + + /// Alias used by the `SpecCommand` flow. + pub fn set_trailing_args_placeholder(&mut self, trailing: Vec) { + self.set_trailing(trailing); + } + + /// Returns whether the switch `id` was present. + #[must_use] + pub fn flag(&self, id: &str) -> bool { + self.slot(id).is_some_and(|ix| self.flags[ix]) + } + + /// Returns the last value recorded for `id`. + #[must_use] + pub fn value(&self, id: &str) -> Option<&str> { + let ix = self.slot(id)?; + self.values.get(ix)?.last().map(String::as_str) + } + + /// Returns all values recorded for `id`, in order. + #[must_use] + pub fn values(&self, id: &str) -> &[String] { + match self.slot(id) { + Some(ix) => &self.values[ix], + None => &[], + } + } + + /// Returns the last value recorded for the positional `id`. + #[must_use] + pub fn value_of_positional(&self, id: &str) -> Option<&str> { + self.positional_values(id).last().map(String::as_str) + } + + /// Returns whether anything was recorded for `id`. + #[must_use] + pub fn has_value(&self, id: &str) -> bool { + self.slot(id).is_some_and(|ix| !self.values[ix].is_empty()) + } + + /// Returns the captured trailing operands. + #[must_use] + pub fn trailing(&self) -> &[String] { + &self.trailing + } +} diff --git a/brush-core/src/argmodel/usage_backend.rs b/brush-core/src/argmodel/usage_backend.rs new file mode 100644 index 000000000..48bffe4cb --- /dev/null +++ b/brush-core/src/argmodel/usage_backend.rs @@ -0,0 +1,340 @@ +//! The usage-rs implementation of the argument-model backend. +//! +//! Builds a `'static` [`usage::argv::Command`] graph from the neutral spec and +//! drives `usage::argv::Parser`'s event stream, mapping flag and positional +//! events back to declaration ids. Help pages render through +//! `usage::argv::help::render_styled`. + +use std::ffi::OsStr; + +use usage::argv::{Arg, ArgAction, Command, DoubleDash, Error as UsageError, Event, Flag, Parser}; + +use super::model::{ArgKind, CommandSpec, ParsedValues}; +use crate::builtins::BuiltinArgParseError; + +/// The usage backend. +pub struct UsageBackend; + +fn render_parse_error( + spec: &usage::argv::spec::Spec<'_>, + argv: &[&OsStr], + err: &UsageError<'_, '_>, +) -> BuiltinArgParseError { + match err { + UsageError::Help { cmd, long } => BuiltinArgParseError { + message: usage::argv::help::render_styled( + spec, + cmd, + *long, + usage::argv::help::Style::auto(), + ) + .unwrap_or_default(), + help_request: true, + }, + UsageError::MissingArgsHelp { cmd } => BuiltinArgParseError { + message: usage::argv::help::render_styled( + spec, + cmd, + false, + usage::argv::help::Style::auto_stderr(), + ) + .unwrap_or_default(), + help_request: false, + }, + UsageError::Version { .. } => BuiltinArgParseError { + message: String::from("Version information requested.\n"), + help_request: true, + }, + _ => BuiltinArgParseError { + message: usage::argv::render_failure(spec, argv, err), + help_request: false, + }, + } +} + +impl super::ArgParserBackend for UsageBackend { + fn parse( + &self, + spec: &'static CommandSpec, + name: &str, + argv: &[String], + ) -> Result { + let command = build_command(spec, name); + let argv_refs: Vec<&OsStr> = argv.iter().map(|arg| OsStr::new(arg)).collect(); + + let file_spec = borrowed_spec(spec, Box::leak(name.to_owned().into_boxed_str())); + let mut parser = Parser::new(command, &argv_refs); + let mut values = ParsedValues::new(spec); + + if std::env::var_os("BRUSH_DBG").is_some() { + std::eprintln!( + "DBG usage parse: {} args in spec, {} pos", + spec.args.len(), + spec.positionals.len() + ); + } + + let dbg = std::env::var_os("BRUSH_DBG").is_some(); + loop { + match parser.next_event() { + None => break, + Some(Err(err)) => { + return Err(render_parse_error(&file_spec, &argv_refs, &err)); + } + Some(Ok(Event::Flag { flag, value, .. })) => { + if dbg { + std::eprintln!("DBG flag event: {flag:?} value={value:?}"); + } + let id = flag.name; + match value { + None => values.set_flag(id), + Some(bytes) => { + let value = usage::argv::as_str(bytes) + .map_err(|err| BuiltinArgParseError { + message: format!("invalid UTF-8 for `{id}`: {err}"), + help_request: false, + })? + .to_owned(); + values.push_value(id, value); + } + } + } + Some(Ok(Event::Arg { arg, value, .. })) => { + if dbg { + std::eprintln!("DBG arg event: name={} value={value:?}", arg.name); + } + // N.B. Positional events carry the positional's id; their + // values live in the positional slots. + let value = usage::argv::as_str(value) + .map_err(|err| { + let id = arg.name; + BuiltinArgParseError { + message: format!("invalid UTF-8 for `{id}`: {err}"), + help_request: false, + } + })? + .to_owned(); + values.push_positional_by_id(arg.name, value); + } + Some(Ok(Event::Command(_))) => {} + Some(Ok(Event::External { .. })) => {} + } + } + + Ok(values) + } + + fn detailed_help( + &self, + spec: &'static CommandSpec, + name: &str, + ) -> Result { + let command = build_command(spec, name); + let help_flag = help_trigger(command); + let file_spec = borrowed_spec(spec, Box::leak(name.to_owned().into_boxed_str())); + let argv: [&OsStr; 1] = [OsStr::new(help_flag)]; + + let mut parser = Parser::new(command, &argv); + match parser.next_event() { + Some(Err(UsageError::Help { cmd, long })) => Ok(usage::argv::help::render_styled( + &file_spec, + cmd, + long, + usage::argv::help::Style::PLAIN, + ) + .unwrap_or_default()), + _ => Err( + crate::error::ErrorKind::Unimplemented("failed to trigger help rendering").into(), + ), + } + } +} + +/// Builds the engine's command graph from the neutral spec. +/// +/// The engine requires `&'static Command<'static>`; the built graph is leaked +/// (bounded by the number of distinct builtin invocations). +#[must_use] +#[expect(clippy::too_many_lines, reason = "explicit engine structure")] +/// Returns the engine's command graph for the given spec and name. +/// +/// Graphs are built once and interned in a process-wide cache keyed by the +/// spec's address plus the command name, so repeated invocations of the same +/// builtin reuse one allocation set instead of leaking per parse. (The +/// usage-argv engine requires `&'static Command<'static>`; interning bounds +/// total leakage to one graph per registered builtin.) +pub fn build_command(spec: &'static CommandSpec, name: &str) -> &'static Command<'static> { + use std::collections::HashMap; + use std::sync::Mutex; + + // N.B. Outer key: the spec's address ('static, stable). Inner key: the + // invocation name, since one spec can serve several registered names + // (declare/readonly/local). Graphs are built once and leaked; total + // leakage is bounded to one graph per (builtin spec, name) pair. + type Cache = Mutex>>>; + static GRAPH_CACHE: std::sync::LazyLock = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + + let spec_addr = std::ptr::from_ref(spec).addr(); + let mut cache = GRAPH_CACHE.lock().expect("command graph cache poisoned"); + + if let Some(cmd) = cache.get(&spec_addr).and_then(|inner| inner.get(name)) { + return cmd; + } + + let cmd = Box::leak(Box::new(build_graph(spec, name))); + cache + .entry(spec_addr) + .or_default() + .insert(name.to_owned(), cmd); + cmd +} + +/// Builds the engine's command graph from the neutral spec. +#[expect(clippy::too_many_lines, reason = "explicit engine structure")] +fn build_graph(spec: &CommandSpec, name: &str) -> Command<'static> { + let mut flags: Vec> = Vec::new(); + for (ix, arg) in spec.args.iter().enumerate() { + let shorts: Vec = arg.shorts.iter().map(|c| *c as u8).collect(); + let longs: Vec<&'static str> = arg.longs.to_vec(); + let key = ix as u64 + 1; + + let flag = match arg.kind { + ArgKind::Flag => Flag { + key, + binding_key: key, + binding_type: None, + name: arg.id, + longs: &longs.leak()[..], + shorts: &shorts.leak()[..], + negate: None, + takes_value: false, + variadic: false, + var_max: None, + delimiter: None, + allow_hyphen_values: false, + allow_negative_numbers: false, + value_terminator: None, + require_equals: false, + value_optional: false, + bool_value: true, + default_missing: None, + global: false, + action: ArgAction::Set, + }, + ArgKind::Value => Flag { + key, + binding_key: key, + binding_type: None, + name: arg.id, + longs: &longs.leak()[..], + shorts: &shorts.leak()[..], + negate: None, + takes_value: true, + variadic: false, + var_max: None, + delimiter: None, + allow_hyphen_values: false, + allow_negative_numbers: false, + value_terminator: None, + require_equals: false, + value_optional: false, + bool_value: false, + default_missing: None, + global: false, + action: ArgAction::Set, + }, + }; + flags.push(flag); + } + + let args: Vec> = spec + .positionals + .iter() + .enumerate() + .map(|(ix, p)| Arg { + key: 1000 + ix as u64, + required: !p.many, + var: p.many, + var_max: None, + delimiter: None, + allow_negative_numbers: false, + value_terminator: None, + double_dash: DoubleDash::Optional, + name: p.id, + }) + .collect(); + + fn leak_flag(flag: Flag<'static>) -> &'static Flag<'static> { + Box::leak(Box::new(flag)) + } + fn leak_arg(arg: Arg<'static>) -> &'static Arg<'static> { + Box::leak(Box::new(arg)) + } + + let flag_refs: &'static [&'static Flag<'static>] = Box::leak( + flags + .into_iter() + .map(leak_flag) + .collect::>() + .into_boxed_slice(), + ); + let arg_refs: &'static [&'static Arg<'static>] = Box::leak( + args.into_iter() + .map(leak_arg) + .collect::>() + .into_boxed_slice(), + ); + + Command { + name: leak_str(name), + aliases: &[], + flags: flag_refs, + args: arg_refs, + subcommands: &[], + default_subcommand: None, + external_subcommand: false, + arg_required_else_help: false, + subcommand_negates_reqs: false, + args_conflicts_with_subcommands: false, + subcommand_precedence_over_arg: false, + allow_missing_positional: false, + dont_delimit_trailing_values: false, + unknown_flags: None, + version: false, + disable_help_flag: false, + disable_help_subcommand: true, + disable_version_flag: true, + key: 0, + } +} + +fn help_trigger(command: &Command<'_>) -> &'static str { + let has_short_help = command + .flags + .iter() + .any(|f| f.shorts.contains(&(b'h')) && f.action == ArgAction::Help); + if has_short_help { "-h" } else { "--help" } +} + +fn leak_str(s: &str) -> &'static str { + Box::leak(s.to_owned().into_boxed_str()) +} + +/// Builds the borrowed help-time spec view. +#[must_use] +pub fn borrowed_spec( + spec: &'static CommandSpec, + name: &'static str, +) -> usage::argv::spec::Spec<'static> { + let cmd = build_command(spec, name); + usage::argv::spec::Spec { + name: leak_str(name), + bin: Some(leak_str(name)), + root: Box::leak(Box::new(usage::argv::spec::CommandMeta { + cmd, + ..usage::argv::spec::CommandMeta::EMPTY + })), + ..usage::argv::spec::Spec::EMPTY + } +} diff --git a/brush-core/src/builtins.rs b/brush-core/src/builtins.rs index ef518a966..f71e103ef 100644 --- a/brush-core/src/builtins.rs +++ b/brush-core/src/builtins.rs @@ -1,12 +1,19 @@ //! Facilities for implementing and managing builtins +use crate::BuiltinError; +pub use crate::argmodel; +#[cfg(feature = "parser-bpaf")] +#[cfg(feature = "parser-bpaf")] pub use bpaf::Parser; +#[cfg(feature = "parser-bpaf")] use bpaf::{Args, ParseFailure}; pub use futures::future::BoxFuture; +#[cfg(feature = "parser-bpaf")] +#[cfg(feature = "parser-bpaf")] use std::ffi::OsStr; use std::io::Write; -use crate::{BuiltinError, CommandArg, commands, error, extensions, results}; +use crate::{CommandArg, commands, error, extensions, results}; /// Type of a function implementing a built-in command. /// @@ -51,7 +58,8 @@ impl std::fmt::Display for BuiltinArgParseError { impl std::error::Error for BuiltinArgParseError {} -fn render_parse_failure(failure: ParseFailure) -> BuiltinArgParseError { +#[cfg(feature = "parser-bpaf")] +pub(crate) 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 { @@ -195,6 +203,7 @@ fn short_group_token_count(group: &str, value_shorts: &str) -> usize { 1 } +#[cfg(feature = "parser-bpaf")] /// Trait implemented by built-in shell commands. pub trait Command: Sized { /// The error type returned by the command. @@ -323,6 +332,7 @@ pub trait Command: Sized { } /// Renders the given command's detailed help text. +#[cfg(feature = "parser-bpaf")] 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. @@ -362,6 +372,7 @@ fn expand_plus_option_groups(args: Vec) -> Vec { /// 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. +#[cfg(feature = "parser-bpaf")] fn parse_options_only(mut options: Vec) -> Result { if T::takes_plus_options() { options = expand_plus_option_groups(options); @@ -371,6 +382,7 @@ fn parse_options_only(mut options: Vec) -> Result(args: &[String]) -> Result { let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); T::parser() @@ -381,6 +393,7 @@ fn run_parser(args: &[String]) -> Result { /// Trait implemented by built-in shell commands that take specially handled declarations /// as arguments. +#[cfg(feature = "parser-bpaf")] pub trait DeclarationCommand: Command { /// Stores the declarations within the command instance. /// @@ -439,6 +452,7 @@ impl Registration { } } +#[cfg(feature = "parser-bpaf")] fn get_builtin_man_page(_name: &str) -> Result { error::unimp("man page rendering is not yet implemented") } @@ -474,6 +488,7 @@ pub fn simple_builtin() -> Registration { Registration { execute_func: exec_builtin::, @@ -487,6 +502,7 @@ pub fn builtin() -> R /// Returns a built-in command registration, given an implementation of the /// `DeclarationCommand` trait. Used for select commands that can take parsed /// declarations as arguments. +#[cfg(feature = "parser-bpaf")] pub fn decl_builtin() -> Registration { Registration { @@ -505,6 +521,7 @@ pub fn decl_builtin( name: &str, content_type: ContentType, @@ -549,6 +567,7 @@ async fn exec_simple_builtin_impl< T::execute(context, plain_args) } +#[cfg(feature = "parser-bpaf")] fn exec_builtin( context: commands::ExecutionContext<'_, SE>, args: Vec, @@ -556,6 +575,7 @@ fn exec_builtin( Box::pin(async move { exec_builtin_impl::(context, args).await }) } +#[cfg(feature = "parser-bpaf")] async fn exec_builtin_impl( context: commands::ExecutionContext<'_, SE>, args: Vec, @@ -580,6 +600,7 @@ async fn exec_builtin_impl, e: &BuiltinArgParseError, @@ -593,6 +614,7 @@ fn report_arg_parse_error( } } +#[cfg(feature = "parser-bpaf")] fn exec_declaration_builtin< T: DeclarationCommand + Send + Sync, SE: extensions::ShellExtensions, @@ -603,6 +625,7 @@ fn exec_declaration_builtin< Box::pin(async move { exec_declaration_builtin_impl::(context, args).await }) } +#[cfg(feature = "parser-bpaf")] async fn exec_declaration_builtin_impl< T: DeclarationCommand + Send + Sync, SE: extensions::ShellExtensions, @@ -634,6 +657,7 @@ async fn exec_declaration_builtin_impl< call_builtin(command, context).await } +#[cfg(feature = "parser-bpaf")] fn exec_raw_arg_builtin< T: DeclarationCommand + Default + Send + Sync, SE: extensions::ShellExtensions, @@ -644,6 +668,7 @@ fn exec_raw_arg_builtin< Box::pin(async move { exec_raw_arg_builtin_impl::(context, args).await }) } +#[cfg(feature = "parser-bpaf")] async fn exec_raw_arg_builtin_impl< T: DeclarationCommand + Default + Send + Sync, SE: extensions::ShellExtensions, @@ -657,6 +682,7 @@ async fn exec_raw_arg_builtin_impl< call_builtin(command, context).await } +#[cfg(feature = "parser-bpaf")] async fn call_builtin( command: impl Command, context: commands::ExecutionContext<'_, impl extensions::ShellExtensions>, @@ -670,6 +696,234 @@ async fn call_builtin( Ok(result) } +/// A built-in command whose argument surface is declared as backend-neutral +/// data ([`crate::argmodel::CommandSpec`]) and materialized from parsed +/// [`crate::argmodel::Matches`]. +/// +/// Which argument-parsing crate turns the spec into a real parser is an +/// implementation detail selected at compile time via the `parser-*` features +/// of this crate; nothing in an implementation of this trait references a +/// specific parsing crate. +pub trait SpecCommand: Sized { + /// The error type returned by the command. + type Error: BuiltinError + 'static; + + /// Returns the command's argument surface as compile-time data. + fn spec() -> &'static crate::argmodel::CommandSpec; + + /// Materializes the command from parsed values. + /// + /// # Arguments + /// + /// * `values` - Parsed values keyed by declaration id, plus trailing + /// verbatim operands. + fn from_matches( + values: &mut crate::argmodel::ParsedValues, + ) -> Result; + + /// One-line description used by the `help` builtin. + fn about() -> &'static str { + "" + } + + /// Synopsis after the command name, used by the `help` builtin. + fn synopsis() -> &'static str { + "" + } + + /// Whether `+`-style option groups should be expanded before parsing. + fn takes_plus_options() -> bool { + false + } + + /// Whether all operands after the option section are captured verbatim. + fn takes_trailing_args() -> bool { + false + } + + /// Short options that take a value (see [`split_option_section`]). + fn value_taking_short_options() -> &'static str { + "" + } + + /// Whether operands (assignments and names) should be handed to the + /// command raw instead of being flattened into words. Declaration-style + /// builtins (`export`, `declare`, `builtin`) need the distinction between + /// [`CommandArg::Assignment`] and plain words. + fn uses_declarations() -> bool { + false + } + + /// Receives the raw declaration operands when + /// [`SpecCommand::uses_declarations`] is set. + fn set_declarations(&mut self, _declarations: Vec) {} + + /// Parses `args` into the command using the selected backend. + 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); + } + + if Self::takes_plus_options() { + args = expand_plus_option_groups(args); + } + + let trailing = if Self::takes_trailing_args() { + let (options, trailing) = + split_option_section(&args, Self::value_taking_short_options(), &[]); + args = options; + Some(trailing) + } else { + None + }; + + let mut values = crate::argmodel::backend().parse(Self::spec(), "", &args)?; + + if let Some(trailing) = trailing { + values.set_trailing_args_placeholder(trailing); + } + + Self::from_matches(&mut values) + } + + /// Executes the built-in command. + // NOTE: desugared async for Send marker, matching the legacy trait. + fn execute( + &self, + context: commands::ExecutionContext<'_, SE>, + ) -> impl std::future::Future> + + std::marker::Send; + + /// Returns help content, rendering detailed help through the backend. + fn get_content( + name: &str, + content_type: ContentType, + _options: &ContentOptions, + ) -> Result { + match content_type { + ContentType::DetailedHelp => { + crate::argmodel::backend().detailed_help(Self::spec(), name) + } + ContentType::ShortUsage => Ok(format!("{name}: {name} {}\n", Self::synopsis())), + ContentType::ShortDescription => Ok(format!("{name} - {}\n", Self::about())), + ContentType::ManPage => error::unimp("man page rendering is not yet implemented"), + } + } +} + +/// Returns a registration for a [`SpecCommand`] implementation. +pub fn spec_builtin() +-> Registration { + Registration { + execute_func: exec_spec_builtin::, + content_func: get_spec_builtin_content::, + disabled: false, + special_builtin: false, + declaration_builtin: B::uses_declarations(), + } +} + +fn get_spec_builtin_content( + name: &str, + content_type: ContentType, + options: &ContentOptions, +) -> Result { + B::get_content(name, content_type, options) +} + +fn exec_spec_builtin( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> BoxFuture<'_, Result> +where + B: SpecCommand + Send + Sync, + SE: extensions::ShellExtensions, +{ + Box::pin(async move { + if B::uses_declarations() { + return exec_spec_builtin_declarations::(context, args).await; + } + + let plain_args: Vec = args.into_iter().map(|a| a.to_string()).collect(); + let command = match B::new(plain_args) { + Ok(c) => c, + Err(e) => return Ok(report_spec_parse_error(&context, &e)), + }; + + let builtin_name = context.command_name.clone(); + command.execute(context).await.map_err(|e| { + crate::error::Error::from(error::ErrorKind::BuiltinError(Box::new(e), builtin_name)) + }) + }) +} + +async fn exec_spec_builtin_declarations( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> Result +where + B: SpecCommand + Send + Sync, + SE: extensions::ShellExtensions, +{ + // N.B. Commands that declare no option surface (e.g., `builtin`) receive + // their operands raw, mirroring the legacy raw-argument builtins. + let declares_options = !B::spec().args.is_empty(); + + let (options, declarations) = if declares_options { + let mut options: Vec = vec![context.command_name.clone()]; + let mut declarations: Vec = Vec::new(); + + // N.B. argv[0] is dropped; option-looking words go to the parser, the + // rest stay raw for `set_declarations`. + for arg in args.into_iter().skip(1) { + match &arg { + CommandArg::String(s) + if s.len() > 1 && (s.starts_with('-') || s.starts_with('+')) => + { + options.push(s.clone()); + } + _ => declarations.push(arg), + } + } + + (options, declarations) + } else { + (vec![context.command_name.clone()], args) + }; + + let mut command = match B::new(options) { + Ok(c) => c, + Err(e) => return Ok(report_spec_parse_error(&context, &e)), + }; + + command.set_declarations(declarations); + + let builtin_name = context.command_name.clone(); + match command.execute(context).await { + Ok(result) => Ok(result), + Err(e) => Err(error::ErrorKind::BuiltinError(Box::new(e), builtin_name).into()), + } +} + +fn report_spec_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() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/brush-core/src/lib.rs b/brush-core/src/lib.rs index ee0cdfaa5..9ca310b04 100644 --- a/brush-core/src/lib.rs +++ b/brush-core/src/lib.rs @@ -1,6 +1,7 @@ //! Core implementation of the brush shell. Implements the shell's abstraction, its interpreter, and //! various facilities used internally by the shell. +pub mod argmodel; pub mod arithmetic; mod braceexpansion; pub mod builtins; diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml index 1cb5654fe..78c58657a 100644 --- a/brush-shell/Cargo.toml +++ b/brush-shell/Cargo.toml @@ -50,6 +50,9 @@ path = "benches/shell.rs" harness = false [features] +parser-bpaf = ["brush-core/parser-bpaf"] +parser-usage = ["brush-core/parser-usage"] +parser-clap = ["brush-core/parser-clap"] default = ["basic", "reedline", "minimal"] basic = ["brush-interactive/basic"] minimal = ["brush-interactive/minimal"]