use std::iter::Peekable;
use thiserror::Error;
#[cfg(doc)]
use yash_env::Env;
use yash_env::option::FromStrError::{Ambiguous, NoSuchOption};
use yash_env::option::Option as ShellOption;
use yash_env::option::State;
use yash_env::option::canonicalize;
use yash_env::option::parse_long;
use yash_env::option::parse_short;
use yash_quote::quoted;
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub enum Source {
#[default]
Stdin,
File { path: String },
String(String),
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub enum InitFile {
None,
#[default]
Default,
File { path: String },
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct Work {
pub source: Source,
pub profile: InitFile,
pub rcfile: InitFile,
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct Run {
pub work: Work,
pub options: Vec<(ShellOption, State)>,
pub arg0: String,
pub positional_params: Vec<String>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Parse {
Run(Run),
Help,
Version,
}
impl From<Run> for Parse {
fn from(run: Run) -> Self {
Parse::Run(run)
}
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum Error {
#[error("unknown option `{0}`")]
UnknownShortOption(char),
#[error("unknown option `{0}`")]
UnknownLongOption(String),
#[error("ambiguous option name `{0}`")]
AmbiguousLongOption(String),
#[error("option `{0}` missing an argument")]
MissingOptionArgument(String),
#[error("option `{0}` does not take an argument")]
UnexpectedOptionArgument(String),
#[error("cannot specify both `-c` and `-s`")]
ConflictingSources,
#[error("cannot negate option `{0}`")]
UnnegatableShortOption(char),
#[error("cannot negate option `{0}`")]
UnnegatableLongOption(String),
#[error("missing command string for `-c`")]
MissingCommandString,
#[error("non-portable option `{0}`")]
NonPortableShortOption(char),
#[error("non-portable option `+{0}`")]
NonPortableShortOptionNegation(char),
#[error("non-portable option name `{}`{}", .0, suggestion(.1))]
NonPortableLongOption(String, Option<String>),
#[error("non-portable use of both `-` and `--` as separators; use `--` alone instead")]
BothSeparators,
#[error(
"option argument not separated from the option name in `{argument}`; use `{separated_spelling}` instead"
)]
UnseparatedOptionArgument {
argument: String,
separated_spelling: String,
},
}
fn suggestion(spelling: &Option<String>) -> String {
match spelling {
Some(spelling) => format!("; use `{spelling}` instead"),
None => String::new(),
}
}
fn is_portable_long_name(name: &str, option: ShellOption, state: State) -> bool {
if option == ShellOption::Portable {
name == "portable"
} else {
option.portable_long_name() == Some((name, state))
}
}
fn portable_spelling(option: ShellOption, state: State) -> Option<String> {
fn flag(state: State, name_state: State) -> char {
if state == name_state { '-' } else { '+' }
}
if option == ShellOption::Portable {
return Some(format!("{}o portable", flag(state, State::On)));
}
if let Some((name, name_state)) = option.portable_long_name() {
return Some(format!("{}o {name}", flag(state, name_state)));
}
let (name, name_state) = option.portable_short_name()?;
Some(format!("{}{name}", flag(state, name_state)))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShortOption {
Shell,
Version,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum LongOption {
Shell(ShellOption, State),
Profile { path: String },
NoProfile,
Rcfile { path: String },
NoRcfile,
Help,
Version,
}
#[derive(Clone, Debug)]
enum NonShellOptionConstructor {
WithoutArgument(LongOption),
WithArgument(fn(String) -> LongOption),
}
impl NonShellOptionConstructor {
fn from_name(name: &str) -> Option<Self> {
if "profile".starts_with(name) {
Some(Self::WithArgument(|path| LongOption::Profile { path }))
} else if "rcfile".starts_with(name) {
Some(Self::WithArgument(|path| LongOption::Rcfile { path }))
} else if "noprofile".starts_with(name) {
Some(Self::WithoutArgument(LongOption::NoProfile))
} else if "norcfile".starts_with(name) {
Some(Self::WithoutArgument(LongOption::NoRcfile))
} else if "help".starts_with(name) {
Some(Self::WithoutArgument(LongOption::Help))
} else if "version".starts_with(name) {
Some(Self::WithoutArgument(LongOption::Version))
} else {
None
}
}
}
pub fn parse<I, S>(args: I) -> Result<Parse, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut args = args.into_iter().map(Into::into).peekable();
let mut result = Run::default();
let mut portable = State::Off;
if let Some(arg0) = args.next_if(|_| true) {
parse_arg0(&arg0, &mut result.options);
result.arg0 = arg0;
}
loop {
if let Some(option) = try_parse_short(&mut args, &mut result.options, &mut portable)? {
match option {
ShortOption::Shell => continue,
ShortOption::Version => return Ok(Parse::Version),
}
}
let Some(option) = try_parse_long(&mut args, &mut portable)? else {
break;
};
match option {
LongOption::Shell(option, state) => result.options.push((option, state)),
LongOption::Profile { path } => {
if result.work.profile != InitFile::None {
result.work.profile = InitFile::File { path }
}
}
LongOption::NoProfile => result.work.profile = InitFile::None,
LongOption::Rcfile { path } => {
if result.work.rcfile != InitFile::None {
result.work.rcfile = InitFile::File { path }
}
}
LongOption::NoRcfile => result.work.rcfile = InitFile::None,
LongOption::Help => return Ok(Parse::Help),
LongOption::Version => return Ok(Parse::Version),
}
}
if let Some(separator) = args.next_if(|arg| arg == "-" || arg == "--")
&& portable == State::On
{
let other = if separator == "-" { "--" } else { "-" };
if args.peek().is_some_and(|arg| arg == other) {
return Err(Error::BothSeparators);
}
}
if result.options.contains(&(ShellOption::CmdLine, State::On)) {
if result.options.contains(&(ShellOption::Stdin, State::On)) {
return Err(Error::ConflictingSources);
}
let command = args.next_if(|_| true).ok_or(Error::MissingCommandString)?;
result.work.source = Source::String(command);
if let Some(name) = args.next_if(|_| true) {
result.arg0 = name;
}
} else if result.options.contains(&(ShellOption::Stdin, State::On)) {
result.work.source = Source::Stdin;
} else {
if let Some(operand) = args.next_if(|_| true) {
result.arg0.clone_from(&operand);
result.work.source = Source::File { path: operand };
}
}
result.positional_params = args.collect();
Ok(Parse::Run(result))
}
fn parse_arg0(arg0: &str, options: &mut Vec<(ShellOption, State)>) {
if arg0.starts_with('-') {
options.push((ShellOption::Login, State::On));
}
if arg0.rsplit('/').next().unwrap_or("") == "sh" {
options.push((ShellOption::PosixlyCorrect, State::On));
}
}
fn try_parse_short<I: Iterator<Item = String>>(
args: &mut Peekable<I>,
option_occurrences: &mut Vec<(ShellOption, State)>,
portable: &mut State,
) -> Result<Option<ShortOption>, Error> {
let Some(mut arg) = args.next_if(|arg| is_short_option(arg)) else {
return Ok(None);
};
let mut chars = arg.chars();
let negate = match chars.next() {
Some('-') => false,
Some('+') => true,
_ => unreachable!(),
};
while let Some(c) = chars.next() {
if c == 'V' {
return if negate {
Err(Error::UnnegatableShortOption('V'))
} else if *portable == State::On {
Err(Error::NonPortableShortOption('V'))
} else {
Ok(Some(ShortOption::Version))
};
}
if c == 'o' {
let attached = chars.as_str();
let attached_index = if attached.is_empty() {
let prev = arg;
arg = args.next().ok_or(Error::MissingOptionArgument(prev))?;
None
} else {
Some(arg.len() - attached.len())
};
let raw_name = &arg[attached_index.unwrap_or(0)..];
let name = canonicalize(raw_name);
match parse_long(&name) {
Ok((option, name_state)) => {
let new_state = if negate { !name_state } else { name_state };
if *portable == State::On
&& !is_portable_long_name(raw_name, option, name_state)
{
return Err(Error::NonPortableLongOption(
raw_name.to_owned(),
portable_spelling(option, new_state),
));
}
if *portable == State::On
&& let Some(index) = attached_index
{
let separated_spelling =
format!("{} {}", quoted(&arg[..index]), quoted(&arg[index..]));
return Err(Error::UnseparatedOptionArgument {
argument: arg,
separated_spelling,
});
}
if option == ShellOption::Portable {
*portable = new_state;
}
option_occurrences.push((option, new_state));
break;
}
Err(NoSuchOption) => return Err(Error::UnknownLongOption(name.into_owned())),
Err(Ambiguous) => return Err(Error::AmbiguousLongOption(name.into_owned())),
}
}
let (option, state) = parse_short(c).ok_or(Error::UnknownShortOption(c))?;
if *portable == State::On {
if option.portable_short_name() != Some((c, state)) {
return Err(Error::NonPortableShortOption(c));
}
if negate && matches!(option, ShellOption::CmdLine | ShellOption::Stdin) {
return Err(Error::NonPortableShortOptionNegation(c));
}
}
option_occurrences.push((option, if negate { !state } else { state }));
}
Ok(Some(ShortOption::Shell))
}
fn is_short_option(arg: &str) -> bool {
let mut chars = arg.chars();
let negate = match chars.next() {
Some('-') => false,
Some('+') => true,
_ => return false,
};
match chars.next() {
Some('-') if !negate => false,
Some('+') if negate => false,
Some(_) => true,
None => false,
}
}
fn try_parse_long<I: Iterator<Item = String>>(
args: &mut Peekable<I>,
portable: &mut State,
) -> Result<Option<LongOption>, Error> {
let Some(arg) = args.next_if(|arg| is_long_option(arg)) else {
return Ok(None);
};
let mut chars = arg.chars();
let negate = match chars.next() {
Some('-') => false,
Some('+') => true,
_ => unreachable!(),
};
chars.next();
let chars = chars.as_str();
let (name, value) = match chars.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (chars, None),
};
let non_shell_option = NonShellOptionConstructor::from_name(name);
let shell_option = parse_long(&canonicalize(chars));
match (non_shell_option, shell_option) {
(_, Err(Ambiguous)) | (Some(_), Ok(_)) => Err(Error::AmbiguousLongOption(arg)),
(None, Err(NoSuchOption)) => Err(Error::UnknownLongOption(arg)),
(Some(_), Err(NoSuchOption)) if negate => Err(Error::UnnegatableLongOption(arg)),
(Some(NonShellOptionConstructor::WithoutArgument(option)), Err(NoSuchOption)) => {
if *portable == State::On {
Err(Error::NonPortableLongOption(arg, None))
} else if value.is_none() {
Ok(Some(option))
} else {
Err(Error::UnexpectedOptionArgument(arg))
}
}
(Some(NonShellOptionConstructor::WithArgument(ctor)), Err(NoSuchOption)) => {
if *portable == State::On {
return Err(Error::NonPortableLongOption(arg, None));
}
let value = match value {
Some(value) => value.to_owned(),
None => match args.next() {
Some(next_arg) => next_arg,
None => return Err(Error::MissingOptionArgument(arg)),
},
};
Ok(Some(ctor(value)))
}
(None, Ok((option, name_state))) => {
let new_state = if negate { !name_state } else { name_state };
if *portable == State::On {
return Err(Error::NonPortableLongOption(
arg,
portable_spelling(option, new_state),
));
}
if option == ShellOption::Portable {
*portable = new_state;
}
Ok(Some(LongOption::Shell(option, new_state)))
}
}
}
fn is_long_option(arg: &str) -> bool {
if let Some(name) = arg.strip_prefix("--") {
!name.is_empty()
} else {
arg.starts_with("++")
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
fn parse<I, S>(args: I) -> Result<Parse, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
use fuzed_iterator::IteratorExt as _;
super::parse(args.into_iter().fuze())
}
#[test]
fn no_arguments() {
assert_eq!(parse([] as [&str; 0]), Ok(Parse::Run(Run::default())));
}
#[test]
fn arg0_only() {
assert_eq!(
parse(["yash"]),
Ok(Parse::Run(Run {
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn run_file() {
assert_eq!(
parse(["yash", "my-script"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "my-script".to_string()
},
..Work::default()
},
arg0: "my-script".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "path/to/script", "-option", "foo", "bar"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "path/to/script".to_string()
},
..Work::default()
},
arg0: "path/to/script".to_string(),
positional_params: vec![
"-option".to_string(),
"foo".to_string(),
"bar".to_string()
],
..Run::default()
})),
);
}
#[test]
fn run_string() {
assert_eq!(
parse(["yash", "-c", "echo"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::String("echo".to_string()),
..Work::default()
},
options: vec![(ShellOption::CmdLine, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-c", "echo", "name"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::String("echo".to_string()),
..Work::default()
},
options: vec![(ShellOption::CmdLine, State::On)],
arg0: "name".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-c", "echo", "name", "foo", "bar"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::String("echo".to_string()),
..Work::default()
},
options: vec![(ShellOption::CmdLine, State::On)],
arg0: "name".to_string(),
positional_params: vec!["foo".to_string(), "bar".to_string()],
}))
);
assert_eq!(
parse(["yash", "--cmd-line", "echo"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::String("echo".to_string()),
..Work::default()
},
options: vec![(ShellOption::CmdLine, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn missing_command_string() {
assert_eq!(parse(["yash", "-c"]), Err(Error::MissingCommandString));
}
#[test]
fn run_stdin() {
assert_eq!(
parse(["yash", "-s"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::Stdin,
..Work::default()
},
options: vec![(ShellOption::Stdin, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-s", "foo", "bar", "-baz"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::Stdin,
..Work::default()
},
options: vec![(ShellOption::Stdin, State::On)],
arg0: "yash".to_string(),
positional_params: vec!["foo".to_string(), "bar".to_string(), "-baz".to_string()],
})),
);
assert_eq!(
parse(["yash", "--stdin"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::Stdin,
..Work::default()
},
options: vec![(ShellOption::Stdin, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn conflicting_sources() {
assert_eq!(parse(["yash", "-cs"]), Err(Error::ConflictingSources));
}
#[test]
fn short_options() {
assert_eq!(
parse(["yash", "-a"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-n"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::Exec, State::Off)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-an"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::On),
(ShellOption::Exec, State::Off)
],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-a", "-nu", "-x"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::On),
(ShellOption::Exec, State::Off),
(ShellOption::Unset, State::Off),
(ShellOption::XTrace, State::On),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn negated_short_options() {
assert_eq!(
parse(["yash", "+a"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::Off)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "+n"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::Exec, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "+an"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::Off),
(ShellOption::Exec, State::On)
],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "+a", "+ns", "+x"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::Off),
(ShellOption::Exec, State::On),
(ShellOption::Stdin, State::Off),
(ShellOption::XTrace, State::Off),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn o_options() {
assert_eq!(
parse(["yash", "-oallexport"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-o", "allexport"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-o", "all-Export", "-o StD+in_"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::On),
(ShellOption::Stdin, State::On),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn negated_o_options() {
assert_eq!(
parse(["yash", "+oallexport"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::Off)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "+o", "allexport"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::Off)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "+o", "all-Export", "+o no_exec"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::Off),
(ShellOption::Exec, State::On),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn long_options() {
assert_eq!(
parse(["yash", "--all-export"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::On)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "--all-export", "--no*un=set"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::On),
(ShellOption::Unset, State::Off),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn negated_long_options() {
assert_eq!(
parse(["yash", "++all-export"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::AllExport, State::Off)],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "++all+export", "++no*un-set"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::Off),
(ShellOption::Unset, State::On),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn profile_option() {
assert_eq!(
parse(["yash", "--profile", "my/file"]),
Ok(Parse::Run(Run {
work: Work {
profile: InitFile::File {
path: "my/file".to_string(),
},
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "--profile=my/file"]),
Ok(Parse::Run(Run {
work: Work {
profile: InitFile::File {
path: "my/file".to_string(),
},
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "--pr=ofile"]),
Ok(Parse::Run(Run {
work: Work {
profile: InitFile::File {
path: "ofile".to_string(),
},
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn missing_profile_option_argument() {
assert_eq!(
parse(["yash", "--profile"]),
Err(Error::MissingOptionArgument("--profile".to_string())),
);
}
#[test]
fn noprofile_option() {
let expected = Ok(Parse::Run(Run {
work: Work {
profile: InitFile::None,
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
}));
assert_eq!(parse(["yash", "--noprofile"]), expected);
assert_eq!(parse(["yash", "--profile=file", "--noprofile"]), expected);
assert_eq!(parse(["yash", "--noprofile", "--profile=file"]), expected);
}
#[test]
fn unexpected_noprofile_option_argument() {
assert_eq!(
parse(["yash", "--noprofile=x"]),
Err(Error::UnexpectedOptionArgument("--noprofile=x".to_string())),
);
}
#[test]
fn rcfile_option() {
assert_eq!(
parse(["yash", "--rcfile", "my/file"]),
Ok(Parse::Run(Run {
work: Work {
rcfile: InitFile::File {
path: "my/file".to_string(),
},
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "--rcfile=my/file"]),
Ok(Parse::Run(Run {
work: Work {
rcfile: InitFile::File {
path: "my/file".to_string(),
},
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "--rc=file"]),
Ok(Parse::Run(Run {
work: Work {
rcfile: InitFile::File {
path: "file".to_string(),
},
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn missing_rcfile_option_argument() {
assert_eq!(
parse(["yash", "--rcfile"]),
Err(Error::MissingOptionArgument("--rcfile".to_string())),
);
}
#[test]
fn norcfile_option() {
let expected = Ok(Parse::Run(Run {
work: Work {
rcfile: InitFile::None,
..Work::default()
},
arg0: "yash".to_string(),
..Run::default()
}));
assert_eq!(parse(["yash", "--norcfile"]), expected);
assert_eq!(parse(["yash", "--rcfile=file", "--norcfile"]), expected);
assert_eq!(parse(["yash", "--norcfile", "--rcfile=file"]), expected);
}
#[test]
fn option_combinations() {
assert_eq!(
parse(["yash", "-a", "--err-exit", "-u"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::On),
(ShellOption::ErrExit, State::On),
(ShellOption::Unset, State::Off),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "-xo", "noclobber", "-il"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::XTrace, State::On),
(ShellOption::Clobber, State::Off),
(ShellOption::Interactive, State::On),
(ShellOption::Login, State::On),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["yash", "--all", "-f", "--posix"]),
Ok(Parse::Run(Run {
options: vec![
(ShellOption::AllExport, State::On),
(ShellOption::Glob, State::Off),
(ShellOption::PosixlyCorrect, State::On),
],
arg0: "yash".to_string(),
..Run::default()
})),
);
}
#[test]
fn double_hyphen_separator_and_operands() {
assert_eq!(
parse(["yash", "--"]),
Ok(Parse::Run(Run {
arg0: "yash".to_string(),
..Default::default()
})),
);
assert_eq!(
parse(["yash", "-a", "--", "file", "arg"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "file".to_string()
},
..Work::default()
},
options: vec![(ShellOption::AllExport, State::On)],
arg0: "file".to_string(),
positional_params: vec!["arg".to_string()],
})),
);
assert_eq!(
parse(["yash", "-a", "--", "--", "arg"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "--".to_string()
},
..Work::default()
},
options: vec![(ShellOption::AllExport, State::On)],
arg0: "--".to_string(),
positional_params: vec!["arg".to_string()],
})),
);
}
#[test]
fn single_hyphen_separator_and_operands() {
assert_eq!(
parse(["yash", "-"]),
Ok(Parse::Run(Run {
arg0: "yash".to_string(),
..Default::default()
})),
);
assert_eq!(
parse(["yash", "-a", "-", "file", "arg"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "file".to_string()
},
..Work::default()
},
options: vec![(ShellOption::AllExport, State::On)],
arg0: "file".to_string(),
positional_params: vec!["arg".to_string()],
})),
);
assert_eq!(
parse(["yash", "-a", "-", "-", "arg"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "-".to_string()
},
..Work::default()
},
options: vec![(ShellOption::AllExport, State::On)],
arg0: "-".to_string(),
positional_params: vec!["arg".to_string()],
})),
);
}
#[test]
fn option_after_operand() {
assert_eq!(
parse(["yash", "-a", "file", "-e"]),
Ok(Parse::Run(Run {
work: Work {
source: Source::File {
path: "file".to_string()
},
..Work::default()
},
options: vec![(ShellOption::AllExport, State::On)],
arg0: "file".to_string(),
positional_params: vec!["-e".to_string()],
})),
);
}
#[test]
fn leading_hyphen_in_arg0_makes_login_shell() {
assert_eq!(
parse(["-yash"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::Login, State::On)],
arg0: "-yash".to_string(),
..Run::default()
})),
);
assert_matches!(
parse(["-/bin/sh"]),
Ok(Parse::Run(run)) => {
assert!(run.options.contains(&(ShellOption::Login, State::On)));
}
);
}
#[test]
fn command_name_sh_enables_posix_mode() {
assert_eq!(
parse(["sh"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::PosixlyCorrect, State::On)],
arg0: "sh".to_string(),
..Run::default()
})),
);
assert_eq!(
parse(["/usr/bin/sh"]),
Ok(Parse::Run(Run {
options: vec![(ShellOption::PosixlyCorrect, State::On)],
arg0: "/usr/bin/sh".to_string(),
..Run::default()
})),
);
assert_matches!(
parse(["-/bin/sh"]),
Ok(Parse::Run(run)) => {
assert!(run.options.contains(&(ShellOption::PosixlyCorrect, State::On)));
}
);
}
#[test]
fn help_option() {
assert_eq!(parse(["yash", "--help"]), Ok(Parse::Help));
assert_eq!(parse(["yash", "-a", "--help", "file"]), Ok(Parse::Help));
}
#[test]
fn version_option() {
assert_eq!(parse(["yash", "-V"]), Ok(Parse::Version));
assert_eq!(parse(["yash", "-aV", "x"]), Ok(Parse::Version));
assert_eq!(parse(["yash", "--version"]), Ok(Parse::Version));
assert_eq!(parse(["yash", "-a", "--version", "x"]), Ok(Parse::Version));
}
#[test]
fn ambiguous_long_option() {
assert_eq!(
parse(["yash", "--no"]),
Err(Error::AmbiguousLongOption("--no".to_string())),
);
assert_eq!(
parse(["yash", "--p"]),
Err(Error::AmbiguousLongOption("--p".to_string())),
);
assert_eq!(
parse(["yash", "--ver=bose"]),
Err(Error::AmbiguousLongOption("--ver=bose".to_string())),
);
}
#[test]
fn non_existing_option() {
assert_eq!(
parse(["yash", "-x", "-y"]),
Err(Error::UnknownShortOption('y')),
);
assert_eq!(parse(["yash", "-CDf"]), Err(Error::UnknownShortOption('D')),);
assert_eq!(
parse(["yash", "--unexisting"]),
Err(Error::UnknownLongOption("--unexisting".to_string())),
);
assert_eq!(
parse(["yash", "--no+un=existing"]),
Err(Error::UnknownLongOption("--no+un=existing".to_string())),
);
}
#[test]
fn unnegatable_short_option() {
assert_eq!(
parse(["yash", "+V"]),
Err(Error::UnnegatableShortOption('V')),
);
}
#[test]
fn unnegatable_long_option() {
assert_eq!(
parse(["yash", "++profile"]),
Err(Error::UnnegatableLongOption("++profile".to_string())),
);
assert_eq!(
parse(["yash", "++vers=ion"]),
Err(Error::UnnegatableLongOption("++vers=ion".to_string())),
);
}
fn assert_portable_options(args: &[&str], expected: &[(ShellOption, State)]) {
let mut all_args = vec!["yash", "-o", "portable"];
all_args.extend_from_slice(args);
let mut all_expected = vec![(ShellOption::Portable, State::On)];
all_expected.extend_from_slice(expected);
assert_matches!(parse(all_args.clone()), Ok(Parse::Run(run)) => {
assert_eq!(run.options, all_expected, "{all_args:?}");
}, "{all_args:?}");
}
#[test]
fn portable_accepts_posix_short_options() {
assert_portable_options(&["-a"], &[(ShellOption::AllExport, State::On)]);
assert_portable_options(&["-C"], &[(ShellOption::Clobber, State::Off)]);
assert_portable_options(&["-i"], &[(ShellOption::Interactive, State::On)]);
assert_portable_options(&["-s"], &[(ShellOption::Stdin, State::On)]);
assert_portable_options(&["+e"], &[(ShellOption::ErrExit, State::Off)]);
assert_portable_options(
&["-ex"],
&[
(ShellOption::ErrExit, State::On),
(ShellOption::XTrace, State::On),
],
);
}
#[test]
fn portable_accepts_posix_o_names() {
assert_portable_options(&["-o", "errexit"], &[(ShellOption::ErrExit, State::On)]);
assert_portable_options(&["-o", "noclobber"], &[(ShellOption::Clobber, State::Off)]);
assert_portable_options(&["+o", "noclobber"], &[(ShellOption::Clobber, State::On)]);
assert_portable_options(&["-o", "nolog"], &[(ShellOption::Log, State::Off)]);
assert_portable_options(&["-o", "vi"], &[(ShellOption::Vi, State::On)]);
}
#[test]
fn portable_rejects_short_option_posix_does_not_specify() {
assert_eq!(
parse(["yash", "-o", "portable", "-l"]),
Err(Error::NonPortableShortOption('l')),
);
assert_eq!(
parse(["yash", "-o", "portable", "-V"]),
Err(Error::NonPortableShortOption('V')),
);
}
#[test]
fn portable_rejects_negated_c_and_s_options() {
assert_eq!(
parse(["yash", "-o", "portable", "+c"]),
Err(Error::NonPortableShortOptionNegation('c')),
);
assert_eq!(
parse(["yash", "-o", "portable", "+s"]),
Err(Error::NonPortableShortOptionNegation('s')),
);
assert_eq!(
parse(["yash", "-o", "portable", "+es"]),
Err(Error::NonPortableShortOptionNegation('s')),
);
}
#[test]
fn portable_accepts_negated_i_option() {
assert_portable_options(&["+i"], &[(ShellOption::Interactive, State::Off)]);
}
#[test]
fn negated_c_and_s_options_are_accepted_without_the_portable_option() {
assert_matches!(parse(["yash", "+c", "+s"]), Ok(Parse::Run(run)) => {
assert_eq!(run.options, [
(ShellOption::CmdLine, State::Off),
(ShellOption::Stdin, State::Off),
]);
});
}
#[test]
fn portable_rejects_o_name_that_is_not_a_posix_spelling() {
for name in ["clobber", "ERREXIT", "errex", "noerrexit", "login"] {
assert_matches!(
parse(["yash", "-o", "portable", "-o", name]),
Err(Error::NonPortableLongOption(reported, _)) => {
assert_eq!(reported, name);
},
"{name}"
);
}
}
#[test]
fn portable_rejects_long_options() {
for arg in ["--errexit", "++errexit", "--noclobber"] {
assert_matches!(
parse(["yash", "-o", "portable", arg]),
Err(Error::NonPortableLongOption(reported, _)) => {
assert_eq!(reported, arg);
},
"{arg}"
);
}
}
#[test]
fn portable_rejects_options_posix_does_not_define() {
for arg in ["--help", "--version", "--noprofile", "--norcfile"] {
assert_eq!(
parse(["yash", "-o", "portable", arg]),
Err(Error::NonPortableLongOption(arg.to_string(), None)),
"{arg}"
);
}
assert_eq!(
parse(["yash", "-o", "portable", "--profile", "f"]),
Err(Error::NonPortableLongOption("--profile".to_string(), None)),
);
assert_eq!(
parse(["yash", "-o", "portable", "--rcfile=f"]),
Err(Error::NonPortableLongOption("--rcfile=f".to_string(), None)),
);
}
#[test]
fn portable_accepts_canonical_spelling_of_portable_option() {
assert_portable_options(&["+o", "portable"], &[(ShellOption::Portable, State::Off)]);
assert_portable_options(&["-o", "portable"], &[(ShellOption::Portable, State::On)]);
}
#[test]
fn portable_rejects_other_spellings_of_portable_option() {
for arg in ["--portable", "++portable"] {
assert_matches!(
parse(["yash", "-o", "portable", arg]),
Err(Error::NonPortableLongOption(reported, _)) => {
assert_eq!(reported, arg);
},
"{arg}"
);
}
assert_matches!(
parse(["yash", "-o", "portable", "-o", "PORTABLE"]),
Err(Error::NonPortableLongOption(..))
);
}
#[test]
fn enabling_portable_affects_only_following_options() {
assert_matches!(parse(["yash", "--errexit", "-o", "portable"]), Ok(Parse::Run(run)) => {
assert_eq!(run.options, [
(ShellOption::ErrExit, State::On),
(ShellOption::Portable, State::On),
]);
});
assert_matches!(parse(["yash", "--rcfile", "f", "--portable"]), Ok(Parse::Run(run)) => {
assert_eq!(run.options, [(ShellOption::Portable, State::On)]);
assert_eq!(run.work.rcfile, InitFile::File { path: "f".to_string() });
});
assert_eq!(
parse(["yash", "--version", "-o", "portable"]),
Ok(Parse::Version)
);
}
#[test]
fn disabling_portable_affects_only_following_options() {
assert_matches!(
parse(["yash", "-o", "portable", "+o", "portable", "--errexit"]),
Ok(Parse::Run(run)) => {
assert_eq!(run.options, [
(ShellOption::Portable, State::On),
(ShellOption::Portable, State::Off),
(ShellOption::ErrExit, State::On),
]);
}
);
}
#[test]
fn unrecognized_option_name_is_reported_rather_than_non_portable() {
assert_eq!(
parse(["yash", "-o", "portable", "-o", "foo"]),
Err(Error::UnknownLongOption("foo".to_string())),
);
assert_eq!(
parse(["yash", "-o", "portable", "--foo"]),
Err(Error::UnknownLongOption("--foo".to_string())),
);
assert_eq!(
parse(["yash", "-o", "portable", "-Z"]),
Err(Error::UnknownShortOption('Z')),
);
}
#[test]
fn portable_rejects_option_argument_attached_to_the_option_name() {
for (arg, separated) in [
("-oerrexit", "-o errexit"),
("-aoerrexit", "-ao errexit"),
("+onoclobber", "+o noclobber"),
("-oportable", "-o portable"),
] {
assert_eq!(
parse(["yash", "-o", "portable", arg]),
Err(Error::UnseparatedOptionArgument {
argument: arg.to_string(),
separated_spelling: separated.to_string()
}),
"{arg}"
);
}
}
#[test]
fn portable_rejects_both_separators() {
assert_eq!(
parse(["yash", "-o", "portable", "-", "--", "file"]),
Err(Error::BothSeparators),
);
assert_eq!(
parse(["yash", "-o", "portable", "--", "-", "file"]),
Err(Error::BothSeparators),
);
}
#[test]
fn portable_accepts_a_single_separator() {
assert_matches!(
parse(["yash", "-o", "portable", "-", "file"]),
Ok(Parse::Run(run)) => assert_eq!(run.arg0, "file")
);
assert_matches!(
parse(["yash", "-o", "portable", "--", "file"]),
Ok(Parse::Run(run)) => assert_eq!(run.arg0, "file")
);
}
#[test]
fn portable_accepts_a_separator_appearing_among_operands() {
assert_matches!(
parse(["yash", "-o", "portable", "-s", "x", "-", "y"]),
Ok(Parse::Run(run)) => {
assert_eq!(run.positional_params, ["x", "-", "y"]);
}
);
assert_matches!(
parse(["yash", "-o", "portable", "-", "file", "--"]),
Ok(Parse::Run(run)) => {
assert_eq!(run.arg0, "file");
assert_eq!(run.positional_params, ["--"]);
}
);
}
#[test]
fn non_portable_option_name_is_reported_before_the_attached_argument() {
assert_matches!(
parse(["yash", "-o", "portable", "-oclobber"]),
Err(Error::NonPortableLongOption(..))
);
}
#[test]
fn non_portable_error_suggests_portable_spelling() {
fn message(args: &[&str]) -> String {
let mut all_args = vec!["yash", "-o", "portable"];
all_args.extend_from_slice(args);
parse(all_args).unwrap_err().to_string()
}
assert_eq!(
message(&["--errexit"]),
"non-portable option name `--errexit`; use `-o errexit` instead",
);
assert_eq!(
message(&["-o", "clobber"]),
"non-portable option name `clobber`; use `+o noclobber` instead",
);
assert_eq!(
message(&["++portable"]),
"non-portable option name `++portable`; use `+o portable` instead",
);
assert_eq!(message(&["--help"]), "non-portable option name `--help`",);
assert_eq!(
message(&["-o", "login"]),
"non-portable option name `login`",
);
}
}