Skip to main content

yash_cli/startup/
args.rs

1// This file is part of yash, an extended POSIX shell.
2// Copyright (C) 2023 WATANABE Yuki
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! Command line argument parser for the shell
18
19use std::iter::Peekable;
20use thiserror::Error;
21#[cfg(doc)]
22use yash_env::Env;
23use yash_env::option::FromStrError::{Ambiguous, NoSuchOption};
24use yash_env::option::Option as ShellOption;
25use yash_env::option::State;
26use yash_env::option::canonicalize;
27use yash_env::option::parse_long;
28use yash_env::option::parse_short;
29use yash_quote::quoted;
30
31/// Input to the main read-eval loop
32#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
33pub enum Source {
34    /// Read from standard input (the `-s` option)
35    #[default]
36    Stdin,
37    /// Read from a file (no option)
38    File { path: String },
39    /// Read from a string (the `-c` option)
40    String(String),
41}
42
43/// Option specifying an initialization file
44#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
45pub enum InitFile {
46    /// No initialization file
47    None,
48    /// Use the default initialization file
49    #[default]
50    Default,
51    /// Use the specified initialization file
52    File { path: String },
53}
54
55/// Specification of what the shell should do after the environment is set up
56#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
57pub struct Work {
58    /// Input source
59    pub source: Source,
60    /// Initialization file for a login shell
61    pub profile: InitFile,
62    /// Initialization file for an interactive shell
63    pub rcfile: InitFile,
64}
65
66#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
67/// Configuration for initializing and running the shell
68pub struct Run {
69    /// What the shell should do after the environment is set up
70    pub work: Work,
71    /// Shell options
72    pub options: Vec<(ShellOption, State)>,
73    /// Value of [`Env::arg0`]
74    pub arg0: String,
75    /// Positional parameters
76    pub positional_params: Vec<String>,
77}
78
79/// Parse result
80#[derive(Clone, Debug, Eq, Hash, PartialEq)]
81pub enum Parse {
82    /// Runs the shell
83    Run(Run),
84    /// Prints help message and exit
85    Help,
86    /// Prints version information and exit
87    Version,
88}
89
90impl From<Run> for Parse {
91    fn from(run: Run) -> Self {
92        Parse::Run(run)
93    }
94}
95
96/// Error in command line parsing
97#[derive(Clone, Debug, Eq, Error, PartialEq)]
98pub enum Error {
99    /// Short option that is not defined in the option specs
100    #[error("unknown option `{0}`")]
101    UnknownShortOption(char),
102
103    /// Long option that is not defined in the option specs
104    #[error("unknown option `{0}`")]
105    UnknownLongOption(String),
106
107    /// Long option that matches the prefix of more than one option name.
108    #[error("ambiguous option name `{0}`")]
109    AmbiguousLongOption(String),
110
111    /// Option missing an argument
112    #[error("option `{0}` missing an argument")]
113    MissingOptionArgument(String),
114
115    /// Argument specified to an option that does not take an argument
116    #[error("option `{0}` does not take an argument")]
117    UnexpectedOptionArgument(String),
118
119    /// The `-c` and `-s` options used together
120    #[error("cannot specify both `-c` and `-s`")]
121    ConflictingSources,
122
123    /// Negated short option that is not a shell option
124    #[error("cannot negate option `{0}`")]
125    UnnegatableShortOption(char),
126
127    /// Negated long option that is not a shell option
128    #[error("cannot negate option `{0}`")]
129    UnnegatableLongOption(String),
130
131    /// The `-c` option without a command string
132    #[error("missing command string for `-c`")]
133    MissingCommandString,
134
135    /// Short option that POSIX does not specify
136    ///
137    /// This error occurs only while the `portable` shell option is on.
138    #[error("non-portable option `{0}`")]
139    NonPortableShortOption(char),
140
141    /// Negated `-c` or `-s` option
142    ///
143    /// POSIX specifies `-c` and `-s` but not their negations, so this error
144    /// occurs only while the `portable` shell option is on.
145    #[error("non-portable option `+{0}`")]
146    NonPortableShortOptionNegation(char),
147
148    /// Option name that POSIX does not specify, or that POSIX spells
149    /// differently
150    ///
151    /// This error occurs only while the `portable` shell option is on. It
152    /// covers the `--name` and `++name` forms, which POSIX does not define at
153    /// all, as well as an `-o` or `+o` argument that is not an exact POSIX
154    /// option name. The second item is a portable spelling with the same
155    /// effect, if there is one.
156    #[error("non-portable option name `{}`{}", .0, suggestion(.1))]
157    NonPortableLongOption(String, Option<String>),
158
159    /// Both `-` and `--` given as option-operand separators
160    ///
161    /// POSIX leaves the results undefined if both `-` and `--` are given, so
162    /// this error occurs only while the `portable` shell option is on.
163    #[error("non-portable use of both `-` and `--` as separators; use `--` alone instead")]
164    BothSeparators,
165
166    /// `-o` or `+o` whose argument is not a separate argument
167    ///
168    /// POSIX requires conforming applications to specify an option argument as
169    /// a separate argument, so this error occurs only while the `portable`
170    /// shell option is on.
171    #[error(
172        "option argument not separated from the option name in `{argument}`; use `{separated_spelling}` instead"
173    )]
174    UnseparatedOptionArgument {
175        /// The offending command line argument
176        argument: String,
177        /// The same argument with the option argument separated
178        separated_spelling: String,
179    },
180}
181
182/// Formats the trailing clause of [`Error::NonPortableLongOption`].
183fn suggestion(spelling: &Option<String>) -> String {
184    match spelling {
185        Some(spelling) => format!("; use `{spelling}` instead"),
186        None => String::new(),
187    }
188}
189
190/// Returns whether the given `-o` option name is one POSIX specifies.
191///
192/// `name` is the name as written by the user, before [canonicalization](canonicalize),
193/// and `state` is the state the name renders, before any negation by `+o`.
194fn is_portable_long_name(name: &str, option: ShellOption, state: State) -> bool {
195    if option == ShellOption::Portable {
196        // POSIX does not specify the `portable` option, but it must remain
197        // possible to turn it off again once it is on. Accept its canonical
198        // spelling only.
199        name == "portable"
200    } else {
201        option.portable_long_name() == Some((name, state))
202    }
203}
204
205/// Returns a portable spelling that would set the option to the given state.
206///
207/// The result is `None` if POSIX specifies no name for the option.
208fn portable_spelling(option: ShellOption, state: State) -> Option<String> {
209    fn flag(state: State, name_state: State) -> char {
210        if state == name_state { '-' } else { '+' }
211    }
212
213    if option == ShellOption::Portable {
214        // See `is_portable_long_name` for why this spelling is accepted.
215        return Some(format!("{}o portable", flag(state, State::On)));
216    }
217    if let Some((name, name_state)) = option.portable_long_name() {
218        return Some(format!("{}o {name}", flag(state, name_state)));
219    }
220    let (name, name_state) = option.portable_short_name()?;
221    Some(format!("{}{name}", flag(state, name_state)))
222}
223
224/// Result of parsing short options
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226enum ShortOption {
227    /// One or more shell options
228    Shell,
229    /// The `-V` option
230    Version,
231}
232
233/// Result of parsing a long option
234#[derive(Clone, Debug, PartialEq, Eq)]
235enum LongOption {
236    Shell(ShellOption, State),
237    Profile { path: String },
238    NoProfile,
239    Rcfile { path: String },
240    NoRcfile,
241    Help,
242    Version,
243}
244
245/// Intermediate object for parsing a long option
246#[derive(Clone, Debug)]
247enum NonShellOptionConstructor {
248    WithoutArgument(LongOption),
249    WithArgument(fn(String) -> LongOption),
250}
251
252impl NonShellOptionConstructor {
253    fn from_name(name: &str) -> Option<Self> {
254        if "profile".starts_with(name) {
255            Some(Self::WithArgument(|path| LongOption::Profile { path }))
256        } else if "rcfile".starts_with(name) {
257            Some(Self::WithArgument(|path| LongOption::Rcfile { path }))
258        } else if "noprofile".starts_with(name) {
259            Some(Self::WithoutArgument(LongOption::NoProfile))
260        } else if "norcfile".starts_with(name) {
261            Some(Self::WithoutArgument(LongOption::NoRcfile))
262        } else if "help".starts_with(name) {
263            Some(Self::WithoutArgument(LongOption::Help))
264        } else if "version".starts_with(name) {
265            Some(Self::WithoutArgument(LongOption::Version))
266        } else {
267            None
268        }
269    }
270}
271
272/// Parses command line arguments.
273pub fn parse<I, S>(args: I) -> Result<Parse, Error>
274where
275    I: IntoIterator<Item = S>,
276    S: Into<String>,
277{
278    let mut args = args.into_iter().map(Into::into).peekable();
279    let mut result = Run::default();
280    // State of the `Portable` option in effect for the argument being parsed.
281    // The arguments are examined in order, so an argument that turns the
282    // option on or off affects only the arguments that follow it.
283    let mut portable = State::Off;
284
285    // Below, we use `args.next_if(|_| true)` instead of `args.next()` to avoid
286    // consuming the `None` value that needs to be seen again.
287
288    // Parse the command name
289    if let Some(arg0) = args.next_if(|_| true) {
290        parse_arg0(&arg0, &mut result.options);
291        result.arg0 = arg0;
292    }
293
294    // Parse options
295    loop {
296        if let Some(option) = try_parse_short(&mut args, &mut result.options, &mut portable)? {
297            match option {
298                ShortOption::Shell => continue,
299                ShortOption::Version => return Ok(Parse::Version),
300            }
301        }
302
303        let Some(option) = try_parse_long(&mut args, &mut portable)? else {
304            break;
305        };
306        match option {
307            LongOption::Shell(option, state) => result.options.push((option, state)),
308            LongOption::Profile { path } => {
309                if result.work.profile != InitFile::None {
310                    result.work.profile = InitFile::File { path }
311                }
312            }
313            LongOption::NoProfile => result.work.profile = InitFile::None,
314            LongOption::Rcfile { path } => {
315                if result.work.rcfile != InitFile::None {
316                    result.work.rcfile = InitFile::File { path }
317                }
318            }
319            LongOption::NoRcfile => result.work.rcfile = InitFile::None,
320            LongOption::Help => return Ok(Parse::Help),
321            LongOption::Version => return Ok(Parse::Version),
322        }
323    }
324
325    // Check portability of - and --
326    if let Some(separator) = args.next_if(|arg| arg == "-" || arg == "--")
327        && portable == State::On
328    {
329        let other = if separator == "-" { "--" } else { "-" };
330        if args.peek().is_some_and(|arg| arg == other) {
331            return Err(Error::BothSeparators);
332        }
333    }
334
335    // Parse operands
336    if result.options.contains(&(ShellOption::CmdLine, State::On)) {
337        if result.options.contains(&(ShellOption::Stdin, State::On)) {
338            return Err(Error::ConflictingSources);
339        }
340
341        let command = args.next_if(|_| true).ok_or(Error::MissingCommandString)?;
342        result.work.source = Source::String(command);
343        if let Some(name) = args.next_if(|_| true) {
344            result.arg0 = name;
345        }
346    } else if result.options.contains(&(ShellOption::Stdin, State::On)) {
347        result.work.source = Source::Stdin;
348    } else {
349        // No -c or -s
350        if let Some(operand) = args.next_if(|_| true) {
351            result.arg0.clone_from(&operand);
352            result.work.source = Source::File { path: operand };
353        }
354    }
355    result.positional_params = args.collect();
356
357    Ok(Parse::Run(result))
358}
359
360fn parse_arg0(arg0: &str, options: &mut Vec<(ShellOption, State)>) {
361    if arg0.starts_with('-') {
362        options.push((ShellOption::Login, State::On));
363    }
364    if arg0.rsplit('/').next().unwrap_or("") == "sh" {
365        options.push((ShellOption::PosixlyCorrect, State::On));
366    }
367}
368
369/// Parses the next argument as short options.
370///
371/// If the next argument is not a short option, returns `Ok(None)`.
372/// If the next argument is a short option, consumes it and returns `Ok(Some(_))`.
373/// The parsed options are added to `option_occurrences`.
374/// If the `-V` option is included, returns `Ok(Some(ShortOption::Version))`.
375///
376/// `portable` is the state of the [`Portable`](ShellOption::Portable) option in
377/// effect for this argument. It is updated when the argument modifies the
378/// option, so that the options that follow are parsed under the new state.
379fn try_parse_short<I: Iterator<Item = String>>(
380    args: &mut Peekable<I>,
381    option_occurrences: &mut Vec<(ShellOption, State)>,
382    portable: &mut State,
383) -> Result<Option<ShortOption>, Error> {
384    let Some(mut arg) = args.next_if(|arg| is_short_option(arg)) else {
385        return Ok(None);
386    };
387
388    let mut chars = arg.chars();
389    let negate = match chars.next() {
390        Some('-') => false,
391        Some('+') => true,
392        _ => unreachable!(),
393    };
394
395    while let Some(c) = chars.next() {
396        if c == 'V' {
397            return if negate {
398                Err(Error::UnnegatableShortOption('V'))
399            } else if *portable == State::On {
400                Err(Error::NonPortableShortOption('V'))
401            } else {
402                Ok(Some(ShortOption::Version))
403            };
404        }
405        if c == 'o' {
406            let attached = chars.as_str();
407            // Byte index where the attached option argument starts,
408            // or `None` if the option argument is a separate argument
409            let attached_index = if attached.is_empty() {
410                let prev = arg;
411                arg = args.next().ok_or(Error::MissingOptionArgument(prev))?;
412                None
413            } else {
414                Some(arg.len() - attached.len())
415            };
416            let raw_name = &arg[attached_index.unwrap_or(0)..];
417            let name = canonicalize(raw_name);
418            match parse_long(&name) {
419                Ok((option, name_state)) => {
420                    let new_state = if negate { !name_state } else { name_state };
421                    if *portable == State::On
422                        && !is_portable_long_name(raw_name, option, name_state)
423                    {
424                        return Err(Error::NonPortableLongOption(
425                            raw_name.to_owned(),
426                            portable_spelling(option, new_state),
427                        ));
428                    }
429                    if *portable == State::On
430                        && let Some(index) = attached_index
431                    {
432                        // How this argument would read with the option argument separated
433                        let separated_spelling =
434                            format!("{} {}", quoted(&arg[..index]), quoted(&arg[index..]));
435                        return Err(Error::UnseparatedOptionArgument {
436                            argument: arg,
437                            separated_spelling,
438                        });
439                    }
440                    if option == ShellOption::Portable {
441                        *portable = new_state;
442                    }
443                    option_occurrences.push((option, new_state));
444                    break;
445                }
446                Err(NoSuchOption) => return Err(Error::UnknownLongOption(name.into_owned())),
447                Err(Ambiguous) => return Err(Error::AmbiguousLongOption(name.into_owned())),
448            }
449        }
450
451        let (option, state) = parse_short(c).ok_or(Error::UnknownShortOption(c))?;
452        if *portable == State::On {
453            if option.portable_short_name() != Some((c, state)) {
454                return Err(Error::NonPortableShortOption(c));
455            }
456            // POSIX's shell invocation synopsis lists `-c` and `-s` apart from
457            // the option letters that may also be written with `+`, so their
458            // negations are not portable.
459            if negate && matches!(option, ShellOption::CmdLine | ShellOption::Stdin) {
460                return Err(Error::NonPortableShortOptionNegation(c));
461            }
462        }
463        option_occurrences.push((option, if negate { !state } else { state }));
464    }
465
466    Ok(Some(ShortOption::Shell))
467}
468
469/// Tests if the given string is a short option.
470fn is_short_option(arg: &str) -> bool {
471    let mut chars = arg.chars();
472    let negate = match chars.next() {
473        Some('-') => false,
474        Some('+') => true,
475        _ => return false,
476    };
477    match chars.next() {
478        Some('-') if !negate => false,
479        Some('+') if negate => false,
480        Some(_) => true,
481        None => false,
482    }
483}
484
485/// Tries to parse and consume the next argument in `args` as a long option.
486///
487/// `portable` is the state of the [`Portable`](ShellOption::Portable) option in
488/// effect for this argument. It is updated when the argument modifies the
489/// option, so that the options that follow are parsed under the new state.
490/// POSIX defines neither the `--name` and `++name` forms nor any of the
491/// initialization-file options, so all of them are rejected while `portable`
492/// is on.
493fn try_parse_long<I: Iterator<Item = String>>(
494    args: &mut Peekable<I>,
495    portable: &mut State,
496) -> Result<Option<LongOption>, Error> {
497    let Some(arg) = args.next_if(|arg| is_long_option(arg)) else {
498        return Ok(None);
499    };
500
501    let mut chars = arg.chars();
502    let negate = match chars.next() {
503        Some('-') => false,
504        Some('+') => true,
505        _ => unreachable!(),
506    };
507
508    // Skip the second `-` or `+`
509    chars.next();
510
511    let chars = chars.as_str();
512
513    // Parse non-shell options
514    let (name, value) = match chars.split_once('=') {
515        Some((name, value)) => (name, Some(value)),
516        None => (chars, None),
517    };
518    let non_shell_option = NonShellOptionConstructor::from_name(name);
519
520    // Parse shell options
521    let shell_option = parse_long(&canonicalize(chars));
522
523    // Check if the result is unique and return the final result
524    match (non_shell_option, shell_option) {
525        (_, Err(Ambiguous)) | (Some(_), Ok(_)) => Err(Error::AmbiguousLongOption(arg)),
526
527        (None, Err(NoSuchOption)) => Err(Error::UnknownLongOption(arg)),
528
529        (Some(_), Err(NoSuchOption)) if negate => Err(Error::UnnegatableLongOption(arg)),
530
531        (Some(NonShellOptionConstructor::WithoutArgument(option)), Err(NoSuchOption)) => {
532            if *portable == State::On {
533                Err(Error::NonPortableLongOption(arg, None))
534            } else if value.is_none() {
535                Ok(Some(option))
536            } else {
537                Err(Error::UnexpectedOptionArgument(arg))
538            }
539        }
540
541        (Some(NonShellOptionConstructor::WithArgument(ctor)), Err(NoSuchOption)) => {
542            if *portable == State::On {
543                return Err(Error::NonPortableLongOption(arg, None));
544            }
545            let value = match value {
546                Some(value) => value.to_owned(),
547                None => match args.next() {
548                    Some(next_arg) => next_arg,
549                    None => return Err(Error::MissingOptionArgument(arg)),
550                },
551            };
552            Ok(Some(ctor(value)))
553        }
554
555        (None, Ok((option, name_state))) => {
556            let new_state = if negate { !name_state } else { name_state };
557            if *portable == State::On {
558                return Err(Error::NonPortableLongOption(
559                    arg,
560                    portable_spelling(option, new_state),
561                ));
562            }
563            if option == ShellOption::Portable {
564                *portable = new_state;
565            }
566            Ok(Some(LongOption::Shell(option, new_state)))
567        }
568    }
569}
570
571/// Tests if the given string is a long option.
572fn is_long_option(arg: &str) -> bool {
573    if let Some(name) = arg.strip_prefix("--") {
574        !name.is_empty()
575    } else {
576        arg.starts_with("++")
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use assert_matches::assert_matches;
584
585    fn parse<I, S>(args: I) -> Result<Parse, Error>
586    where
587        I: IntoIterator<Item = S>,
588        S: Into<String>,
589    {
590        use fuzed_iterator::IteratorExt as _;
591        super::parse(args.into_iter().fuze())
592    }
593
594    #[test]
595    fn no_arguments() {
596        assert_eq!(parse([] as [&str; 0]), Ok(Parse::Run(Run::default())));
597    }
598
599    #[test]
600    fn arg0_only() {
601        assert_eq!(
602            parse(["yash"]),
603            Ok(Parse::Run(Run {
604                arg0: "yash".to_string(),
605                ..Run::default()
606            })),
607        );
608    }
609
610    #[test]
611    fn run_file() {
612        // Without positional parameters
613        assert_eq!(
614            parse(["yash", "my-script"]),
615            Ok(Parse::Run(Run {
616                work: Work {
617                    source: Source::File {
618                        path: "my-script".to_string()
619                    },
620                    ..Work::default()
621                },
622                arg0: "my-script".to_string(),
623                ..Run::default()
624            })),
625        );
626
627        // With positional parameters
628        assert_eq!(
629            parse(["yash", "path/to/script", "-option", "foo", "bar"]),
630            Ok(Parse::Run(Run {
631                work: Work {
632                    source: Source::File {
633                        path: "path/to/script".to_string()
634                    },
635                    ..Work::default()
636                },
637                arg0: "path/to/script".to_string(),
638                positional_params: vec![
639                    "-option".to_string(),
640                    "foo".to_string(),
641                    "bar".to_string()
642                ],
643                ..Run::default()
644            })),
645        );
646    }
647
648    #[test]
649    fn run_string() {
650        // Without command name or positional parameters
651        assert_eq!(
652            parse(["yash", "-c", "echo"]),
653            Ok(Parse::Run(Run {
654                work: Work {
655                    source: Source::String("echo".to_string()),
656                    ..Work::default()
657                },
658                options: vec![(ShellOption::CmdLine, State::On)],
659                arg0: "yash".to_string(),
660                ..Run::default()
661            })),
662        );
663
664        // With command name but no positional parameters
665        assert_eq!(
666            parse(["yash", "-c", "echo", "name"]),
667            Ok(Parse::Run(Run {
668                work: Work {
669                    source: Source::String("echo".to_string()),
670                    ..Work::default()
671                },
672                options: vec![(ShellOption::CmdLine, State::On)],
673                arg0: "name".to_string(),
674                ..Run::default()
675            })),
676        );
677
678        // With command name and positional parameters
679        assert_eq!(
680            parse(["yash", "-c", "echo", "name", "foo", "bar"]),
681            Ok(Parse::Run(Run {
682                work: Work {
683                    source: Source::String("echo".to_string()),
684                    ..Work::default()
685                },
686                options: vec![(ShellOption::CmdLine, State::On)],
687                arg0: "name".to_string(),
688                positional_params: vec!["foo".to_string(), "bar".to_string()],
689            }))
690        );
691
692        // long option
693        assert_eq!(
694            parse(["yash", "--cmd-line", "echo"]),
695            Ok(Parse::Run(Run {
696                work: Work {
697                    source: Source::String("echo".to_string()),
698                    ..Work::default()
699                },
700                options: vec![(ShellOption::CmdLine, State::On)],
701                arg0: "yash".to_string(),
702                ..Run::default()
703            })),
704        );
705    }
706
707    #[test]
708    fn missing_command_string() {
709        assert_eq!(parse(["yash", "-c"]), Err(Error::MissingCommandString));
710    }
711
712    #[test]
713    fn run_stdin() {
714        // Without positional parameters
715        assert_eq!(
716            parse(["yash", "-s"]),
717            Ok(Parse::Run(Run {
718                work: Work {
719                    source: Source::Stdin,
720                    ..Work::default()
721                },
722                options: vec![(ShellOption::Stdin, State::On)],
723                arg0: "yash".to_string(),
724                ..Run::default()
725            })),
726        );
727
728        // With positional parameters
729        assert_eq!(
730            parse(["yash", "-s", "foo", "bar", "-baz"]),
731            Ok(Parse::Run(Run {
732                work: Work {
733                    source: Source::Stdin,
734                    ..Work::default()
735                },
736                options: vec![(ShellOption::Stdin, State::On)],
737                arg0: "yash".to_string(),
738                positional_params: vec!["foo".to_string(), "bar".to_string(), "-baz".to_string()],
739            })),
740        );
741
742        // long option
743        assert_eq!(
744            parse(["yash", "--stdin"]),
745            Ok(Parse::Run(Run {
746                work: Work {
747                    source: Source::Stdin,
748                    ..Work::default()
749                },
750                options: vec![(ShellOption::Stdin, State::On)],
751                arg0: "yash".to_string(),
752                ..Run::default()
753            })),
754        );
755    }
756
757    #[test]
758    fn conflicting_sources() {
759        assert_eq!(parse(["yash", "-cs"]), Err(Error::ConflictingSources));
760    }
761
762    #[test]
763    fn short_options() {
764        // Single short option
765        assert_eq!(
766            parse(["yash", "-a"]),
767            Ok(Parse::Run(Run {
768                options: vec![(ShellOption::AllExport, State::On)],
769                arg0: "yash".to_string(),
770                ..Run::default()
771            })),
772        );
773        assert_eq!(
774            parse(["yash", "-n"]),
775            Ok(Parse::Run(Run {
776                options: vec![(ShellOption::Exec, State::Off)],
777                arg0: "yash".to_string(),
778                ..Run::default()
779            })),
780        );
781
782        // Combined short options
783        assert_eq!(
784            parse(["yash", "-an"]),
785            Ok(Parse::Run(Run {
786                options: vec![
787                    (ShellOption::AllExport, State::On),
788                    (ShellOption::Exec, State::Off)
789                ],
790                arg0: "yash".to_string(),
791                ..Run::default()
792            })),
793        );
794
795        // Many short options
796        assert_eq!(
797            parse(["yash", "-a", "-nu", "-x"]),
798            Ok(Parse::Run(Run {
799                options: vec![
800                    (ShellOption::AllExport, State::On),
801                    (ShellOption::Exec, State::Off),
802                    (ShellOption::Unset, State::Off),
803                    (ShellOption::XTrace, State::On),
804                ],
805                arg0: "yash".to_string(),
806                ..Run::default()
807            })),
808        );
809    }
810
811    #[test]
812    fn negated_short_options() {
813        // Single short option
814        assert_eq!(
815            parse(["yash", "+a"]),
816            Ok(Parse::Run(Run {
817                options: vec![(ShellOption::AllExport, State::Off)],
818                arg0: "yash".to_string(),
819                ..Run::default()
820            })),
821        );
822        assert_eq!(
823            parse(["yash", "+n"]),
824            Ok(Parse::Run(Run {
825                options: vec![(ShellOption::Exec, State::On)],
826                arg0: "yash".to_string(),
827                ..Run::default()
828            })),
829        );
830
831        // Combined short options
832        assert_eq!(
833            parse(["yash", "+an"]),
834            Ok(Parse::Run(Run {
835                options: vec![
836                    (ShellOption::AllExport, State::Off),
837                    (ShellOption::Exec, State::On)
838                ],
839                arg0: "yash".to_string(),
840                ..Run::default()
841            })),
842        );
843
844        // Many short options
845        assert_eq!(
846            parse(["yash", "+a", "+ns", "+x"]),
847            Ok(Parse::Run(Run {
848                options: vec![
849                    (ShellOption::AllExport, State::Off),
850                    (ShellOption::Exec, State::On),
851                    (ShellOption::Stdin, State::Off),
852                    (ShellOption::XTrace, State::Off),
853                ],
854                arg0: "yash".to_string(),
855                ..Run::default()
856            })),
857        );
858    }
859
860    #[test]
861    fn o_options() {
862        // Adjoined o options
863        assert_eq!(
864            parse(["yash", "-oallexport"]),
865            Ok(Parse::Run(Run {
866                options: vec![(ShellOption::AllExport, State::On)],
867                arg0: "yash".to_string(),
868                ..Run::default()
869            })),
870        );
871
872        // Separate o options
873        assert_eq!(
874            parse(["yash", "-o", "allexport"]),
875            Ok(Parse::Run(Run {
876                options: vec![(ShellOption::AllExport, State::On)],
877                arg0: "yash".to_string(),
878                ..Run::default()
879            })),
880        );
881
882        // Non-canonical o options
883        assert_eq!(
884            parse(["yash", "-o", "all-Export", "-o StD+in_"]),
885            Ok(Parse::Run(Run {
886                options: vec![
887                    (ShellOption::AllExport, State::On),
888                    (ShellOption::Stdin, State::On),
889                ],
890                arg0: "yash".to_string(),
891                ..Run::default()
892            })),
893        );
894    }
895
896    #[test]
897    fn negated_o_options() {
898        // Adjoined o options
899        assert_eq!(
900            parse(["yash", "+oallexport"]),
901            Ok(Parse::Run(Run {
902                options: vec![(ShellOption::AllExport, State::Off)],
903                arg0: "yash".to_string(),
904                ..Run::default()
905            })),
906        );
907
908        // Separate o options
909        assert_eq!(
910            parse(["yash", "+o", "allexport"]),
911            Ok(Parse::Run(Run {
912                options: vec![(ShellOption::AllExport, State::Off)],
913                arg0: "yash".to_string(),
914                ..Run::default()
915            })),
916        );
917
918        // Non-canonical o options
919        assert_eq!(
920            parse(["yash", "+o", "all-Export", "+o no_exec"]),
921            Ok(Parse::Run(Run {
922                options: vec![
923                    (ShellOption::AllExport, State::Off),
924                    (ShellOption::Exec, State::On),
925                ],
926                arg0: "yash".to_string(),
927                ..Run::default()
928            })),
929        );
930    }
931
932    #[test]
933    fn long_options() {
934        assert_eq!(
935            parse(["yash", "--all-export"]),
936            Ok(Parse::Run(Run {
937                options: vec![(ShellOption::AllExport, State::On)],
938                arg0: "yash".to_string(),
939                ..Run::default()
940            })),
941        );
942        assert_eq!(
943            parse(["yash", "--all-export", "--no*un=set"]),
944            Ok(Parse::Run(Run {
945                options: vec![
946                    (ShellOption::AllExport, State::On),
947                    (ShellOption::Unset, State::Off),
948                ],
949                arg0: "yash".to_string(),
950                ..Run::default()
951            })),
952        );
953    }
954
955    #[test]
956    fn negated_long_options() {
957        assert_eq!(
958            parse(["yash", "++all-export"]),
959            Ok(Parse::Run(Run {
960                options: vec![(ShellOption::AllExport, State::Off)],
961                arg0: "yash".to_string(),
962                ..Run::default()
963            })),
964        );
965        assert_eq!(
966            parse(["yash", "++all+export", "++no*un-set"]),
967            Ok(Parse::Run(Run {
968                options: vec![
969                    (ShellOption::AllExport, State::Off),
970                    (ShellOption::Unset, State::On),
971                ],
972                arg0: "yash".to_string(),
973                ..Run::default()
974            })),
975        );
976    }
977
978    #[test]
979    fn profile_option() {
980        // Separate argument
981        assert_eq!(
982            parse(["yash", "--profile", "my/file"]),
983            Ok(Parse::Run(Run {
984                work: Work {
985                    profile: InitFile::File {
986                        path: "my/file".to_string(),
987                    },
988                    ..Work::default()
989                },
990                arg0: "yash".to_string(),
991                ..Run::default()
992            })),
993        );
994
995        // Adjoined argument
996        assert_eq!(
997            parse(["yash", "--profile=my/file"]),
998            Ok(Parse::Run(Run {
999                work: Work {
1000                    profile: InitFile::File {
1001                        path: "my/file".to_string(),
1002                    },
1003                    ..Work::default()
1004                },
1005                arg0: "yash".to_string(),
1006                ..Run::default()
1007            })),
1008        );
1009
1010        // Abbreviated option name
1011        assert_eq!(
1012            parse(["yash", "--pr=ofile"]),
1013            Ok(Parse::Run(Run {
1014                work: Work {
1015                    profile: InitFile::File {
1016                        path: "ofile".to_string(),
1017                    },
1018                    ..Work::default()
1019                },
1020                arg0: "yash".to_string(),
1021                ..Run::default()
1022            })),
1023        );
1024    }
1025
1026    #[test]
1027    fn missing_profile_option_argument() {
1028        assert_eq!(
1029            parse(["yash", "--profile"]),
1030            Err(Error::MissingOptionArgument("--profile".to_string())),
1031        );
1032    }
1033
1034    #[test]
1035    fn noprofile_option() {
1036        let expected = Ok(Parse::Run(Run {
1037            work: Work {
1038                profile: InitFile::None,
1039                ..Work::default()
1040            },
1041            arg0: "yash".to_string(),
1042            ..Run::default()
1043        }));
1044        assert_eq!(parse(["yash", "--noprofile"]), expected);
1045
1046        // noprofile option wins over profile option
1047        assert_eq!(parse(["yash", "--profile=file", "--noprofile"]), expected);
1048        assert_eq!(parse(["yash", "--noprofile", "--profile=file"]), expected);
1049    }
1050
1051    #[test]
1052    fn unexpected_noprofile_option_argument() {
1053        assert_eq!(
1054            parse(["yash", "--noprofile=x"]),
1055            Err(Error::UnexpectedOptionArgument("--noprofile=x".to_string())),
1056        );
1057    }
1058
1059    #[test]
1060    fn rcfile_option() {
1061        // Separate argument
1062        assert_eq!(
1063            parse(["yash", "--rcfile", "my/file"]),
1064            Ok(Parse::Run(Run {
1065                work: Work {
1066                    rcfile: InitFile::File {
1067                        path: "my/file".to_string(),
1068                    },
1069                    ..Work::default()
1070                },
1071                arg0: "yash".to_string(),
1072                ..Run::default()
1073            })),
1074        );
1075
1076        // Adjoined argument
1077        assert_eq!(
1078            parse(["yash", "--rcfile=my/file"]),
1079            Ok(Parse::Run(Run {
1080                work: Work {
1081                    rcfile: InitFile::File {
1082                        path: "my/file".to_string(),
1083                    },
1084                    ..Work::default()
1085                },
1086                arg0: "yash".to_string(),
1087                ..Run::default()
1088            })),
1089        );
1090
1091        // Abbreviated option name
1092        assert_eq!(
1093            parse(["yash", "--rc=file"]),
1094            Ok(Parse::Run(Run {
1095                work: Work {
1096                    rcfile: InitFile::File {
1097                        path: "file".to_string(),
1098                    },
1099                    ..Work::default()
1100                },
1101                arg0: "yash".to_string(),
1102                ..Run::default()
1103            })),
1104        );
1105    }
1106
1107    #[test]
1108    fn missing_rcfile_option_argument() {
1109        assert_eq!(
1110            parse(["yash", "--rcfile"]),
1111            Err(Error::MissingOptionArgument("--rcfile".to_string())),
1112        );
1113    }
1114
1115    #[test]
1116    fn norcfile_option() {
1117        let expected = Ok(Parse::Run(Run {
1118            work: Work {
1119                rcfile: InitFile::None,
1120                ..Work::default()
1121            },
1122            arg0: "yash".to_string(),
1123            ..Run::default()
1124        }));
1125        assert_eq!(parse(["yash", "--norcfile"]), expected);
1126
1127        // norcfile option wins over rcfile option
1128        assert_eq!(parse(["yash", "--rcfile=file", "--norcfile"]), expected);
1129        assert_eq!(parse(["yash", "--norcfile", "--rcfile=file"]), expected);
1130    }
1131
1132    #[test]
1133    fn option_combinations() {
1134        assert_eq!(
1135            parse(["yash", "-a", "--err-exit", "-u"]),
1136            Ok(Parse::Run(Run {
1137                options: vec![
1138                    (ShellOption::AllExport, State::On),
1139                    (ShellOption::ErrExit, State::On),
1140                    (ShellOption::Unset, State::Off),
1141                ],
1142                arg0: "yash".to_string(),
1143                ..Run::default()
1144            })),
1145        );
1146
1147        assert_eq!(
1148            parse(["yash", "-xo", "noclobber", "-il"]),
1149            Ok(Parse::Run(Run {
1150                options: vec![
1151                    (ShellOption::XTrace, State::On),
1152                    (ShellOption::Clobber, State::Off),
1153                    (ShellOption::Interactive, State::On),
1154                    (ShellOption::Login, State::On),
1155                ],
1156                arg0: "yash".to_string(),
1157                ..Run::default()
1158            })),
1159        );
1160
1161        assert_eq!(
1162            parse(["yash", "--all", "-f", "--posix"]),
1163            Ok(Parse::Run(Run {
1164                options: vec![
1165                    (ShellOption::AllExport, State::On),
1166                    (ShellOption::Glob, State::Off),
1167                    (ShellOption::PosixlyCorrect, State::On),
1168                ],
1169                arg0: "yash".to_string(),
1170                ..Run::default()
1171            })),
1172        );
1173    }
1174
1175    #[test]
1176    fn double_hyphen_separator_and_operands() {
1177        assert_eq!(
1178            parse(["yash", "--"]),
1179            Ok(Parse::Run(Run {
1180                arg0: "yash".to_string(),
1181                ..Default::default()
1182            })),
1183        );
1184
1185        assert_eq!(
1186            parse(["yash", "-a", "--", "file", "arg"]),
1187            Ok(Parse::Run(Run {
1188                work: Work {
1189                    source: Source::File {
1190                        path: "file".to_string()
1191                    },
1192                    ..Work::default()
1193                },
1194                options: vec![(ShellOption::AllExport, State::On)],
1195                arg0: "file".to_string(),
1196                positional_params: vec!["arg".to_string()],
1197            })),
1198        );
1199
1200        assert_eq!(
1201            parse(["yash", "-a", "--", "--", "arg"]),
1202            Ok(Parse::Run(Run {
1203                work: Work {
1204                    source: Source::File {
1205                        path: "--".to_string()
1206                    },
1207                    ..Work::default()
1208                },
1209                options: vec![(ShellOption::AllExport, State::On)],
1210                arg0: "--".to_string(),
1211                positional_params: vec!["arg".to_string()],
1212            })),
1213        );
1214    }
1215
1216    #[test]
1217    fn single_hyphen_separator_and_operands() {
1218        assert_eq!(
1219            parse(["yash", "-"]),
1220            Ok(Parse::Run(Run {
1221                arg0: "yash".to_string(),
1222                ..Default::default()
1223            })),
1224        );
1225
1226        assert_eq!(
1227            parse(["yash", "-a", "-", "file", "arg"]),
1228            Ok(Parse::Run(Run {
1229                work: Work {
1230                    source: Source::File {
1231                        path: "file".to_string()
1232                    },
1233                    ..Work::default()
1234                },
1235                options: vec![(ShellOption::AllExport, State::On)],
1236                arg0: "file".to_string(),
1237                positional_params: vec!["arg".to_string()],
1238            })),
1239        );
1240
1241        assert_eq!(
1242            parse(["yash", "-a", "-", "-", "arg"]),
1243            Ok(Parse::Run(Run {
1244                work: Work {
1245                    source: Source::File {
1246                        path: "-".to_string()
1247                    },
1248                    ..Work::default()
1249                },
1250                options: vec![(ShellOption::AllExport, State::On)],
1251                arg0: "-".to_string(),
1252                positional_params: vec!["arg".to_string()],
1253            })),
1254        );
1255    }
1256
1257    #[test]
1258    fn option_after_operand() {
1259        assert_eq!(
1260            parse(["yash", "-a", "file", "-e"]),
1261            Ok(Parse::Run(Run {
1262                work: Work {
1263                    source: Source::File {
1264                        path: "file".to_string()
1265                    },
1266                    ..Work::default()
1267                },
1268                options: vec![(ShellOption::AllExport, State::On)],
1269                arg0: "file".to_string(),
1270                positional_params: vec!["-e".to_string()],
1271            })),
1272        );
1273    }
1274
1275    #[test]
1276    fn leading_hyphen_in_arg0_makes_login_shell() {
1277        assert_eq!(
1278            parse(["-yash"]),
1279            Ok(Parse::Run(Run {
1280                options: vec![(ShellOption::Login, State::On)],
1281                arg0: "-yash".to_string(),
1282                ..Run::default()
1283            })),
1284        );
1285
1286        assert_matches!(
1287            parse(["-/bin/sh"]),
1288            Ok(Parse::Run(run)) => {
1289                assert!(run.options.contains(&(ShellOption::Login, State::On)));
1290            }
1291        );
1292    }
1293
1294    #[test]
1295    fn command_name_sh_enables_posix_mode() {
1296        assert_eq!(
1297            parse(["sh"]),
1298            Ok(Parse::Run(Run {
1299                options: vec![(ShellOption::PosixlyCorrect, State::On)],
1300                arg0: "sh".to_string(),
1301                ..Run::default()
1302            })),
1303        );
1304        assert_eq!(
1305            parse(["/usr/bin/sh"]),
1306            Ok(Parse::Run(Run {
1307                options: vec![(ShellOption::PosixlyCorrect, State::On)],
1308                arg0: "/usr/bin/sh".to_string(),
1309                ..Run::default()
1310            })),
1311        );
1312
1313        assert_matches!(
1314            parse(["-/bin/sh"]),
1315            Ok(Parse::Run(run)) => {
1316                assert!(run.options.contains(&(ShellOption::PosixlyCorrect, State::On)));
1317            }
1318        );
1319    }
1320
1321    #[test]
1322    fn help_option() {
1323        assert_eq!(parse(["yash", "--help"]), Ok(Parse::Help));
1324        assert_eq!(parse(["yash", "-a", "--help", "file"]), Ok(Parse::Help));
1325    }
1326
1327    #[test]
1328    fn version_option() {
1329        assert_eq!(parse(["yash", "-V"]), Ok(Parse::Version));
1330        assert_eq!(parse(["yash", "-aV", "x"]), Ok(Parse::Version));
1331
1332        assert_eq!(parse(["yash", "--version"]), Ok(Parse::Version));
1333        assert_eq!(parse(["yash", "-a", "--version", "x"]), Ok(Parse::Version));
1334    }
1335
1336    #[test]
1337    fn ambiguous_long_option() {
1338        assert_eq!(
1339            parse(["yash", "--no"]),
1340            Err(Error::AmbiguousLongOption("--no".to_string())),
1341        );
1342        assert_eq!(
1343            parse(["yash", "--p"]),
1344            Err(Error::AmbiguousLongOption("--p".to_string())),
1345        );
1346        assert_eq!(
1347            parse(["yash", "--ver=bose"]),
1348            Err(Error::AmbiguousLongOption("--ver=bose".to_string())),
1349        );
1350    }
1351
1352    #[test]
1353    fn non_existing_option() {
1354        assert_eq!(
1355            parse(["yash", "-x", "-y"]),
1356            Err(Error::UnknownShortOption('y')),
1357        );
1358        assert_eq!(parse(["yash", "-CDf"]), Err(Error::UnknownShortOption('D')),);
1359
1360        assert_eq!(
1361            parse(["yash", "--unexisting"]),
1362            Err(Error::UnknownLongOption("--unexisting".to_string())),
1363        );
1364        assert_eq!(
1365            parse(["yash", "--no+un=existing"]),
1366            Err(Error::UnknownLongOption("--no+un=existing".to_string())),
1367        );
1368    }
1369
1370    #[test]
1371    fn unnegatable_short_option() {
1372        assert_eq!(
1373            parse(["yash", "+V"]),
1374            Err(Error::UnnegatableShortOption('V')),
1375        );
1376    }
1377
1378    #[test]
1379    fn unnegatable_long_option() {
1380        assert_eq!(
1381            parse(["yash", "++profile"]),
1382            Err(Error::UnnegatableLongOption("++profile".to_string())),
1383        );
1384        assert_eq!(
1385            parse(["yash", "++vers=ion"]),
1386            Err(Error::UnnegatableLongOption("++vers=ion".to_string())),
1387        );
1388    }
1389
1390    /// Asserts that the arguments, prefixed with `-o portable`, are accepted
1391    /// and yield the given shell options in addition to `Portable`.
1392    fn assert_portable_options(args: &[&str], expected: &[(ShellOption, State)]) {
1393        let mut all_args = vec!["yash", "-o", "portable"];
1394        all_args.extend_from_slice(args);
1395        let mut all_expected = vec![(ShellOption::Portable, State::On)];
1396        all_expected.extend_from_slice(expected);
1397        assert_matches!(parse(all_args.clone()), Ok(Parse::Run(run)) => {
1398            assert_eq!(run.options, all_expected, "{all_args:?}");
1399        }, "{all_args:?}");
1400    }
1401
1402    #[test]
1403    fn portable_accepts_posix_short_options() {
1404        assert_portable_options(&["-a"], &[(ShellOption::AllExport, State::On)]);
1405        assert_portable_options(&["-C"], &[(ShellOption::Clobber, State::Off)]);
1406        assert_portable_options(&["-i"], &[(ShellOption::Interactive, State::On)]);
1407        assert_portable_options(&["-s"], &[(ShellOption::Stdin, State::On)]);
1408        assert_portable_options(&["+e"], &[(ShellOption::ErrExit, State::Off)]);
1409        assert_portable_options(
1410            &["-ex"],
1411            &[
1412                (ShellOption::ErrExit, State::On),
1413                (ShellOption::XTrace, State::On),
1414            ],
1415        );
1416    }
1417
1418    #[test]
1419    fn portable_accepts_posix_o_names() {
1420        assert_portable_options(&["-o", "errexit"], &[(ShellOption::ErrExit, State::On)]);
1421        assert_portable_options(&["-o", "noclobber"], &[(ShellOption::Clobber, State::Off)]);
1422        assert_portable_options(&["+o", "noclobber"], &[(ShellOption::Clobber, State::On)]);
1423        assert_portable_options(&["-o", "nolog"], &[(ShellOption::Log, State::Off)]);
1424        assert_portable_options(&["-o", "vi"], &[(ShellOption::Vi, State::On)]);
1425    }
1426
1427    #[test]
1428    fn portable_rejects_short_option_posix_does_not_specify() {
1429        assert_eq!(
1430            parse(["yash", "-o", "portable", "-l"]),
1431            Err(Error::NonPortableShortOption('l')),
1432        );
1433        assert_eq!(
1434            parse(["yash", "-o", "portable", "-V"]),
1435            Err(Error::NonPortableShortOption('V')),
1436        );
1437    }
1438
1439    #[test]
1440    fn portable_rejects_negated_c_and_s_options() {
1441        assert_eq!(
1442            parse(["yash", "-o", "portable", "+c"]),
1443            Err(Error::NonPortableShortOptionNegation('c')),
1444        );
1445        assert_eq!(
1446            parse(["yash", "-o", "portable", "+s"]),
1447            Err(Error::NonPortableShortOptionNegation('s')),
1448        );
1449        assert_eq!(
1450            parse(["yash", "-o", "portable", "+es"]),
1451            Err(Error::NonPortableShortOptionNegation('s')),
1452        );
1453    }
1454
1455    #[test]
1456    fn portable_accepts_negated_i_option() {
1457        assert_portable_options(&["+i"], &[(ShellOption::Interactive, State::Off)]);
1458    }
1459
1460    #[test]
1461    fn negated_c_and_s_options_are_accepted_without_the_portable_option() {
1462        assert_matches!(parse(["yash", "+c", "+s"]), Ok(Parse::Run(run)) => {
1463            assert_eq!(run.options, [
1464                (ShellOption::CmdLine, State::Off),
1465                (ShellOption::Stdin, State::Off),
1466            ]);
1467        });
1468    }
1469
1470    #[test]
1471    fn portable_rejects_o_name_that_is_not_a_posix_spelling() {
1472        for name in ["clobber", "ERREXIT", "errex", "noerrexit", "login"] {
1473            assert_matches!(
1474                parse(["yash", "-o", "portable", "-o", name]),
1475                Err(Error::NonPortableLongOption(reported, _)) => {
1476                    assert_eq!(reported, name);
1477                },
1478                "{name}"
1479            );
1480        }
1481    }
1482
1483    #[test]
1484    fn portable_rejects_long_options() {
1485        for arg in ["--errexit", "++errexit", "--noclobber"] {
1486            assert_matches!(
1487                parse(["yash", "-o", "portable", arg]),
1488                Err(Error::NonPortableLongOption(reported, _)) => {
1489                    assert_eq!(reported, arg);
1490                },
1491                "{arg}"
1492            );
1493        }
1494    }
1495
1496    #[test]
1497    fn portable_rejects_options_posix_does_not_define() {
1498        for arg in ["--help", "--version", "--noprofile", "--norcfile"] {
1499            assert_eq!(
1500                parse(["yash", "-o", "portable", arg]),
1501                Err(Error::NonPortableLongOption(arg.to_string(), None)),
1502                "{arg}"
1503            );
1504        }
1505        assert_eq!(
1506            parse(["yash", "-o", "portable", "--profile", "f"]),
1507            Err(Error::NonPortableLongOption("--profile".to_string(), None)),
1508        );
1509        assert_eq!(
1510            parse(["yash", "-o", "portable", "--rcfile=f"]),
1511            Err(Error::NonPortableLongOption("--rcfile=f".to_string(), None)),
1512        );
1513    }
1514
1515    #[test]
1516    fn portable_accepts_canonical_spelling_of_portable_option() {
1517        assert_portable_options(&["+o", "portable"], &[(ShellOption::Portable, State::Off)]);
1518        assert_portable_options(&["-o", "portable"], &[(ShellOption::Portable, State::On)]);
1519    }
1520
1521    #[test]
1522    fn portable_rejects_other_spellings_of_portable_option() {
1523        for arg in ["--portable", "++portable"] {
1524            assert_matches!(
1525                parse(["yash", "-o", "portable", arg]),
1526                Err(Error::NonPortableLongOption(reported, _)) => {
1527                    assert_eq!(reported, arg);
1528                },
1529                "{arg}"
1530            );
1531        }
1532        assert_matches!(
1533            parse(["yash", "-o", "portable", "-o", "PORTABLE"]),
1534            Err(Error::NonPortableLongOption(..))
1535        );
1536    }
1537
1538    #[test]
1539    fn enabling_portable_affects_only_following_options() {
1540        assert_matches!(parse(["yash", "--errexit", "-o", "portable"]), Ok(Parse::Run(run)) => {
1541            assert_eq!(run.options, [
1542                (ShellOption::ErrExit, State::On),
1543                (ShellOption::Portable, State::On),
1544            ]);
1545        });
1546
1547        assert_matches!(parse(["yash", "--rcfile", "f", "--portable"]), Ok(Parse::Run(run)) => {
1548            assert_eq!(run.options, [(ShellOption::Portable, State::On)]);
1549            assert_eq!(run.work.rcfile, InitFile::File { path: "f".to_string() });
1550        });
1551
1552        assert_eq!(
1553            parse(["yash", "--version", "-o", "portable"]),
1554            Ok(Parse::Version)
1555        );
1556    }
1557
1558    #[test]
1559    fn disabling_portable_affects_only_following_options() {
1560        assert_matches!(
1561            parse(["yash", "-o", "portable", "+o", "portable", "--errexit"]),
1562            Ok(Parse::Run(run)) => {
1563                assert_eq!(run.options, [
1564                    (ShellOption::Portable, State::On),
1565                    (ShellOption::Portable, State::Off),
1566                    (ShellOption::ErrExit, State::On),
1567                ]);
1568            }
1569        );
1570    }
1571
1572    #[test]
1573    fn unrecognized_option_name_is_reported_rather_than_non_portable() {
1574        assert_eq!(
1575            parse(["yash", "-o", "portable", "-o", "foo"]),
1576            Err(Error::UnknownLongOption("foo".to_string())),
1577        );
1578        assert_eq!(
1579            parse(["yash", "-o", "portable", "--foo"]),
1580            Err(Error::UnknownLongOption("--foo".to_string())),
1581        );
1582        assert_eq!(
1583            parse(["yash", "-o", "portable", "-Z"]),
1584            Err(Error::UnknownShortOption('Z')),
1585        );
1586    }
1587
1588    #[test]
1589    fn portable_rejects_option_argument_attached_to_the_option_name() {
1590        for (arg, separated) in [
1591            ("-oerrexit", "-o errexit"),
1592            ("-aoerrexit", "-ao errexit"),
1593            ("+onoclobber", "+o noclobber"),
1594            ("-oportable", "-o portable"),
1595        ] {
1596            assert_eq!(
1597                parse(["yash", "-o", "portable", arg]),
1598                Err(Error::UnseparatedOptionArgument {
1599                    argument: arg.to_string(),
1600                    separated_spelling: separated.to_string()
1601                }),
1602                "{arg}"
1603            );
1604        }
1605    }
1606
1607    #[test]
1608    fn portable_rejects_both_separators() {
1609        assert_eq!(
1610            parse(["yash", "-o", "portable", "-", "--", "file"]),
1611            Err(Error::BothSeparators),
1612        );
1613        assert_eq!(
1614            parse(["yash", "-o", "portable", "--", "-", "file"]),
1615            Err(Error::BothSeparators),
1616        );
1617    }
1618
1619    #[test]
1620    fn portable_accepts_a_single_separator() {
1621        assert_matches!(
1622            parse(["yash", "-o", "portable", "-", "file"]),
1623            Ok(Parse::Run(run)) => assert_eq!(run.arg0, "file")
1624        );
1625        assert_matches!(
1626            parse(["yash", "-o", "portable", "--", "file"]),
1627            Ok(Parse::Run(run)) => assert_eq!(run.arg0, "file")
1628        );
1629    }
1630
1631    #[test]
1632    fn portable_accepts_a_separator_appearing_among_operands() {
1633        assert_matches!(
1634            parse(["yash", "-o", "portable", "-s", "x", "-", "y"]),
1635            Ok(Parse::Run(run)) => {
1636                assert_eq!(run.positional_params, ["x", "-", "y"]);
1637            }
1638        );
1639        assert_matches!(
1640            parse(["yash", "-o", "portable", "-", "file", "--"]),
1641            Ok(Parse::Run(run)) => {
1642                assert_eq!(run.arg0, "file");
1643                assert_eq!(run.positional_params, ["--"]);
1644            }
1645        );
1646    }
1647
1648    #[test]
1649    fn non_portable_option_name_is_reported_before_the_attached_argument() {
1650        assert_matches!(
1651            parse(["yash", "-o", "portable", "-oclobber"]),
1652            Err(Error::NonPortableLongOption(..))
1653        );
1654    }
1655
1656    #[test]
1657    fn non_portable_error_suggests_portable_spelling() {
1658        fn message(args: &[&str]) -> String {
1659            let mut all_args = vec!["yash", "-o", "portable"];
1660            all_args.extend_from_slice(args);
1661            parse(all_args).unwrap_err().to_string()
1662        }
1663
1664        assert_eq!(
1665            message(&["--errexit"]),
1666            "non-portable option name `--errexit`; use `-o errexit` instead",
1667        );
1668        assert_eq!(
1669            message(&["-o", "clobber"]),
1670            "non-portable option name `clobber`; use `+o noclobber` instead",
1671        );
1672        assert_eq!(
1673            message(&["++portable"]),
1674            "non-portable option name `++portable`; use `+o portable` instead",
1675        );
1676        assert_eq!(message(&["--help"]), "non-portable option name `--help`",);
1677        assert_eq!(
1678            message(&["-o", "login"]),
1679            "non-portable option name `login`",
1680        );
1681    }
1682}