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