403 lines
14 KiB
Rust
403 lines
14 KiB
Rust
use std::{collections::HashMap, io::Write};
|
|
|
|
use clap::Parser;
|
|
|
|
use brush_core::{ExecutionResult, builtins, env, variables};
|
|
|
|
/// Parse command options.
|
|
#[derive(Parser)]
|
|
pub(crate) struct GetOptsCommand {
|
|
/// Specification for options
|
|
options_string: String,
|
|
|
|
/// Name of variable to receive next option
|
|
variable_name: String,
|
|
|
|
/// Arguments to parse
|
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
args: Vec<String>,
|
|
}
|
|
|
|
// We track cross-call state in special variables. They are hidden from enumeration
|
|
// (e.g. `set`, `declare`) so they don't leak into scripts' environments.
|
|
const VAR_GETOPTS_NEXT_CHAR_INDEX: &str = "__GETOPTS_NEXT_CHAR";
|
|
const VAR_GETOPTS_LAST_OPTIND: &str = "__GETOPTS_LAST_OPTIND";
|
|
const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
|
|
|
|
/// The result of processing one option from the argument list.
|
|
struct GetOptsResult {
|
|
/// The value to assign to the target variable (the option char, `?`, or `:`).
|
|
variable_value: String,
|
|
/// The value for OPTARG, if any (option's argument or, on error, the offending char).
|
|
optarg: Option<String>,
|
|
/// The new value for OPTIND after this call.
|
|
optind: usize,
|
|
/// Exit code: success (0) if an option was found, general error (1) when done.
|
|
exit_code: ExecutionResult,
|
|
}
|
|
|
|
/// Parsed representation of the optstring (e.g., `":a:bc"`).
|
|
struct OptionSpec {
|
|
/// Maps each option character to whether it requires an argument.
|
|
defs: HashMap<char, bool>,
|
|
/// True when the optstring has a leading `:`, suppressing error messages
|
|
/// and reporting errors via `?`/`:` in the variable and OPTARG instead.
|
|
silent_errors: bool,
|
|
}
|
|
|
|
/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error
|
|
/// mode. Each letter defines an option; a `:` immediately after a letter means it
|
|
/// takes an argument. Duplicate letters are ignored (first definition wins).
|
|
fn parse_option_spec(spec: &str) -> OptionSpec {
|
|
let mut defs = HashMap::<char, bool>::new();
|
|
let mut silent_errors = false;
|
|
|
|
let mut last_char = None;
|
|
for c in spec.chars() {
|
|
if c == ':' {
|
|
if let Some(last_char) = last_char {
|
|
defs.insert(last_char, true);
|
|
} else if defs.is_empty() {
|
|
// First character is ':' — request silent error reporting.
|
|
silent_errors = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) {
|
|
e.insert(false);
|
|
last_char = Some(c);
|
|
} else {
|
|
// Duplicate option char; first definition wins.
|
|
// Clear last_char so a trailing colon doesn't modify the first definition.
|
|
last_char = None;
|
|
}
|
|
}
|
|
|
|
OptionSpec {
|
|
defs,
|
|
silent_errors,
|
|
}
|
|
}
|
|
|
|
impl builtins::Command for GetOptsCommand {
|
|
type Error = brush_core::Error;
|
|
|
|
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
|
|
/// to `--`. See [`builtins::parse_known`] for more information
|
|
/// TODO(command): we can safely remove this after the issue is resolved
|
|
fn new<I>(args: I) -> Result<Self, clap::Error>
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
|
|
if let Some(args) = rest_args {
|
|
this.args.extend(args);
|
|
}
|
|
Ok(this)
|
|
}
|
|
|
|
async fn execute<SE: brush_core::ShellExtensions>(
|
|
&self,
|
|
mut context: brush_core::ExecutionContext<'_, SE>,
|
|
) -> Result<brush_core::ExecutionResult, Self::Error> {
|
|
// Validate the target variable name.
|
|
if !env::valid_variable_name(&self.variable_name) {
|
|
writeln!(
|
|
context.stderr(),
|
|
"{}: `{}': not a valid identifier",
|
|
context.command_name,
|
|
self.variable_name
|
|
)?;
|
|
return Ok(ExecutionResult::new(1));
|
|
}
|
|
|
|
let spec = parse_option_spec(&self.options_string);
|
|
|
|
// If unset or non-numeric, assume OPTIND is 1.
|
|
let next_index_signed = context
|
|
.shell
|
|
.env_str("OPTIND")
|
|
.and_then(|s| brush_core::int_utils::parse::<i32>(s.as_ref(), 10).ok())
|
|
.unwrap_or(1);
|
|
|
|
// Detect external OPTIND modifications (e.g., `OPTIND=1` to restart
|
|
// parsing). If the current OPTIND differs from what we last set, clear
|
|
// the internal char index so we don't resume mid-arg.
|
|
let last_optind = context
|
|
.shell
|
|
.env_str(VAR_GETOPTS_LAST_OPTIND)
|
|
.and_then(|s| s.parse::<i32>().ok());
|
|
if last_optind != Some(next_index_signed) {
|
|
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
|
|
}
|
|
|
|
#[allow(clippy::cast_sign_loss)] // .max(1) guarantees positive
|
|
let next_index = next_index_signed.max(1) as usize;
|
|
|
|
// Select the arguments to parse. If none were explicitly provided, we
|
|
// default to using the shell's current positional parameters.
|
|
// Clone positional params to avoid borrowing context immutably while we
|
|
// also need it mutably in parse_next_option.
|
|
let owned_args;
|
|
let args_to_parse = if !self.args.is_empty() {
|
|
&self.args
|
|
} else {
|
|
owned_args = context.shell.current_shell_args().to_vec();
|
|
&owned_args
|
|
};
|
|
|
|
let result = parse_next_option(&mut context, &spec, args_to_parse, next_index)?;
|
|
|
|
update_variables(&mut context, &self.variable_name, result)
|
|
}
|
|
}
|
|
|
|
/// Extracts the next option from `args_to_parse` starting at 1-based position
|
|
/// `next_index`. Handles combined flags (e.g., `-abc`), option arguments (both
|
|
/// `-pVALUE` and `-p VALUE` forms), and error reporting for unknown options or
|
|
/// missing arguments. Tracks position within combined flags via the
|
|
/// `__GETOPTS_NEXT_CHAR` shell variable.
|
|
fn parse_next_option<SE: brush_core::ShellExtensions>(
|
|
context: &mut brush_core::ExecutionContext<'_, SE>,
|
|
spec: &OptionSpec,
|
|
args_to_parse: &[String],
|
|
mut next_index: usize,
|
|
) -> Result<GetOptsResult, brush_core::Error> {
|
|
// See if there are any args left to parse.
|
|
if next_index > args_to_parse.len() {
|
|
return Ok(GetOptsResult {
|
|
variable_value: String::from("?"),
|
|
optarg: None,
|
|
// Normalize OPTIND to one past the last argument.
|
|
optind: args_to_parse.len() + 1,
|
|
exit_code: ExecutionResult::general_error(),
|
|
});
|
|
}
|
|
|
|
let arg = args_to_parse[next_index - 1].as_str();
|
|
|
|
// See if this is an option.
|
|
if !arg.starts_with('-') || arg == "--" || arg == "-" {
|
|
// Not an option. If it was "--", skip past it.
|
|
return Ok(GetOptsResult {
|
|
variable_value: String::from("?"),
|
|
optarg: None,
|
|
optind: if arg == "--" {
|
|
next_index + 1
|
|
} else {
|
|
next_index
|
|
},
|
|
exit_code: ExecutionResult::general_error(),
|
|
});
|
|
}
|
|
|
|
// Figure out how far into this option we are.
|
|
let mut next_char_index = context
|
|
.shell
|
|
.env_str(VAR_GETOPTS_NEXT_CHAR_INDEX)
|
|
.map_or(DEFAULT_NEXT_CHAR_INDEX, |s| {
|
|
s.parse().unwrap_or(DEFAULT_NEXT_CHAR_INDEX)
|
|
});
|
|
|
|
// Find the char. If the index is stale (exceeds this arg's length),
|
|
// reset to the default index, mirroring bash behavior.
|
|
let mut c = arg.chars().nth(next_char_index);
|
|
if c.is_none() {
|
|
next_char_index = DEFAULT_NEXT_CHAR_INDEX;
|
|
c = arg.chars().nth(next_char_index);
|
|
}
|
|
let Some(c) = c else {
|
|
// Arg is too short even at default index.
|
|
return Ok(GetOptsResult {
|
|
variable_value: String::from("?"),
|
|
optarg: None,
|
|
optind: next_index,
|
|
exit_code: ExecutionResult::general_error(),
|
|
});
|
|
};
|
|
|
|
let arg_char_count = arg.chars().count();
|
|
let mut is_last_char_in_option = next_char_index == arg_char_count - 1;
|
|
|
|
let mut variable_value;
|
|
let optarg;
|
|
|
|
// Look up the char in the option spec.
|
|
if let Some(takes_arg) = spec.defs.get(&c) {
|
|
variable_value = String::from(c);
|
|
|
|
if *takes_arg {
|
|
(variable_value, optarg, is_last_char_in_option, next_index) = resolve_option_argument(
|
|
context,
|
|
spec,
|
|
c,
|
|
arg,
|
|
args_to_parse,
|
|
next_char_index,
|
|
is_last_char_in_option,
|
|
next_index,
|
|
)?;
|
|
} else {
|
|
optarg = None;
|
|
}
|
|
} else {
|
|
(variable_value, optarg) = report_unknown_option(context, spec, c)?;
|
|
}
|
|
|
|
let optind = if is_last_char_in_option {
|
|
// We're done with this argument, so unset the internal char index variable
|
|
// and request an update to OPTIND.
|
|
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
|
|
next_index + 1
|
|
} else {
|
|
// We have more to go in this argument, so update the internal char index
|
|
// and request that OPTIND not be updated.
|
|
context.shell.env_mut().update_or_add(
|
|
VAR_GETOPTS_NEXT_CHAR_INDEX,
|
|
variables::ShellValueLiteral::Scalar((next_char_index + 1).to_string()),
|
|
|v| {
|
|
v.hide_from_enumeration();
|
|
Ok(())
|
|
},
|
|
brush_core::env::EnvironmentLookup::Anywhere,
|
|
brush_core::env::EnvironmentScope::Global,
|
|
)?;
|
|
next_index
|
|
};
|
|
|
|
Ok(GetOptsResult {
|
|
variable_value,
|
|
optarg,
|
|
optind,
|
|
exit_code: ExecutionResult::success(),
|
|
})
|
|
}
|
|
|
|
/// Resolves the argument for an option that takes a value. Returns the updated
|
|
/// `(variable_value, optarg, is_last_char, next_index)` tuple.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn resolve_option_argument<SE: brush_core::ShellExtensions>(
|
|
context: &brush_core::ExecutionContext<'_, SE>,
|
|
spec: &OptionSpec,
|
|
c: char,
|
|
arg: &str,
|
|
args_to_parse: &[String],
|
|
next_char_index: usize,
|
|
is_last_char_in_option: bool,
|
|
mut next_index: usize,
|
|
) -> Result<(String, Option<String>, bool, usize), brush_core::Error> {
|
|
// If this is the last character in the token, the argument value comes from
|
|
// the next token. Otherwise, the remainder of the current token is the value.
|
|
if is_last_char_in_option {
|
|
if next_index >= args_to_parse.len() {
|
|
// Missing required argument.
|
|
let (variable_value, optarg) = if spec.silent_errors {
|
|
(String::from(":"), Some(String::from(c)))
|
|
} else {
|
|
if is_opterr_enabled(context) {
|
|
writeln!(
|
|
context.stderr(),
|
|
"getopts: option requires an argument -- {c}"
|
|
)?;
|
|
}
|
|
(String::from("?"), None)
|
|
};
|
|
Ok((variable_value, optarg, true, next_index))
|
|
} else {
|
|
next_index += 1;
|
|
Ok((
|
|
String::from(c),
|
|
Some(args_to_parse[next_index - 1].clone()),
|
|
true,
|
|
next_index,
|
|
))
|
|
}
|
|
} else {
|
|
let optarg: String = arg.chars().skip(next_char_index + 1).collect();
|
|
Ok((String::from(c), Some(optarg), true, next_index))
|
|
}
|
|
}
|
|
|
|
/// Handles an unknown option character, reporting an error if appropriate.
|
|
fn report_unknown_option<SE: brush_core::ShellExtensions>(
|
|
context: &brush_core::ExecutionContext<'_, SE>,
|
|
spec: &OptionSpec,
|
|
c: char,
|
|
) -> Result<(String, Option<String>), brush_core::Error> {
|
|
if !spec.silent_errors && is_opterr_enabled(context) {
|
|
writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
|
|
}
|
|
|
|
let optarg = if spec.silent_errors {
|
|
Some(String::from(c))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok((String::from("?"), optarg))
|
|
}
|
|
|
|
/// Writes the parsing result back into shell variables: the target variable,
|
|
/// OPTARG, OPTIND, and the internal `__GETOPTS_LAST_OPTIND` tracker.
|
|
fn update_variables<SE: brush_core::ShellExtensions>(
|
|
context: &mut brush_core::ExecutionContext<'_, SE>,
|
|
variable_name: &str,
|
|
result: GetOptsResult,
|
|
) -> Result<ExecutionResult, brush_core::Error> {
|
|
// Update variable value.
|
|
context.shell.env_mut().update_or_add(
|
|
variable_name,
|
|
variables::ShellValueLiteral::Scalar(result.variable_value),
|
|
|_| Ok(()),
|
|
brush_core::env::EnvironmentLookup::Anywhere,
|
|
brush_core::env::EnvironmentScope::Global,
|
|
)?;
|
|
|
|
// Update OPTARG
|
|
if let Some(optarg) = result.optarg {
|
|
context.shell.env_mut().update_or_add(
|
|
"OPTARG",
|
|
variables::ShellValueLiteral::Scalar(optarg),
|
|
|_| Ok(()),
|
|
brush_core::env::EnvironmentLookup::Anywhere,
|
|
brush_core::env::EnvironmentScope::Global,
|
|
)?;
|
|
} else {
|
|
context.shell.env_mut().unset("OPTARG")?;
|
|
}
|
|
|
|
// Update OPTIND and record it so we can detect external modifications.
|
|
let optind_str = result.optind.to_string();
|
|
context.shell.env_mut().update_or_add(
|
|
"OPTIND",
|
|
variables::ShellValueLiteral::Scalar(optind_str.clone()),
|
|
|_| Ok(()),
|
|
brush_core::env::EnvironmentLookup::Anywhere,
|
|
brush_core::env::EnvironmentScope::Global,
|
|
)?;
|
|
context.shell.env_mut().update_or_add(
|
|
VAR_GETOPTS_LAST_OPTIND,
|
|
variables::ShellValueLiteral::Scalar(optind_str),
|
|
|v| {
|
|
v.hide_from_enumeration();
|
|
Ok(())
|
|
},
|
|
brush_core::env::EnvironmentLookup::Anywhere,
|
|
brush_core::env::EnvironmentScope::Global,
|
|
)?;
|
|
|
|
Ok(result.exit_code)
|
|
}
|
|
|
|
/// Returns whether OPTERR is enabled (i.e., getopts should print error messages).
|
|
/// OPTERR defaults to 1; any nonzero value means errors are enabled.
|
|
fn is_opterr_enabled<SE: brush_core::ShellExtensions>(
|
|
context: &brush_core::ExecutionContext<'_, SE>,
|
|
) -> bool {
|
|
context
|
|
.shell
|
|
.env_str("OPTERR")
|
|
.is_none_or(|s| s.parse::<i64>().unwrap_or(1) != 0)
|
|
}
|