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;
29
30/// Input to the main read-eval loop
31#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
32pub enum Source {
33    /// Read from standard input (the `-s` option)
34    #[default]
35    Stdin,
36    /// Read from a file (no option)
37    File { path: String },
38    /// Read from a string (the `-c` option)
39    String(String),
40}
41
42/// Option specifying an initialization file
43#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
44pub enum InitFile {
45    /// No initialization file
46    None,
47    /// Use the default initialization file
48    #[default]
49    Default,
50    /// Use the specified initialization file
51    File { path: String },
52}
53
54/// Specification of what the shell should do after the environment is set up
55#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
56pub struct Work {
57    /// Input source
58    pub source: Source,
59    /// Initialization file for a login shell
60    pub profile: InitFile,
61    /// Initialization file for an interactive shell
62    pub rcfile: InitFile,
63}
64
65#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
66/// Configuration for initializing and running the shell
67pub struct Run {
68    /// What the shell should do after the environment is set up
69    pub work: Work,
70    /// Shell options
71    pub options: Vec<(ShellOption, State)>,
72    /// Value of [`Env::arg0`]
73    pub arg0: String,
74    /// Positional parameters
75    pub positional_params: Vec<String>,
76}
77
78/// Parse result
79#[derive(Clone, Debug, Eq, Hash, PartialEq)]
80pub enum Parse {
81    /// Runs the shell
82    Run(Run),
83    /// Prints help message and exit
84    Help,
85    /// Prints version information and exit
86    Version,
87}
88
89impl From<Run> for Parse {
90    fn from(run: Run) -> Self {
91        Parse::Run(run)
92    }
93}
94
95/// Error in command line parsing
96#[derive(Clone, Debug, Eq, Error, PartialEq)]
97pub enum Error {
98    /// Short option that is not defined in the option specs
99    #[error("unknown option `{0}`")]
100    UnknownShortOption(char),
101
102    /// Long option that is not defined in the option specs
103    #[error("unknown option `{0}`")]
104    UnknownLongOption(String),
105
106    /// Long option that matches the prefix of more than one option name.
107    #[error("ambiguous option name `{0}`")]
108    AmbiguousLongOption(String),
109
110    /// Option missing an argument
111    #[error("option `{0}` missing an argument")]
112    MissingOptionArgument(String),
113
114    /// Argument specified to an option that does not take an argument
115    #[error("option `{0}` does not take an argument")]
116    UnexpectedOptionArgument(String),
117
118    /// The `-c` and `-s` options used together
119    #[error("cannot specify both `-c` and `-s`")]
120    ConflictingSources,
121
122    /// Negated short option that is not a shell option
123    #[error("cannot negate option `{0}`")]
124    UnnegatableShortOption(char),
125
126    /// Negated long option that is not a shell option
127    #[error("cannot negate option `{0}`")]
128    UnnegatableLongOption(String),
129
130    /// The `-c` option without a command string
131    #[error("missing command string for `-c`")]
132    MissingCommandString,
133}
134
135/// Result of parsing short options
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137enum ShortOption {
138    /// One or more shell options
139    Shell,
140    /// The `-V` option
141    Version,
142}
143
144/// Result of parsing a long option
145#[derive(Clone, Debug, PartialEq, Eq)]
146enum LongOption {
147    Shell(ShellOption, State),
148    Profile { path: String },
149    NoProfile,
150    Rcfile { path: String },
151    NoRcfile,
152    Help,
153    Version,
154}
155
156/// Intermediate object for parsing a long option
157#[derive(Clone, Debug, PartialEq, Eq)]
158enum NonShellOptionConstructor {
159    WithoutArgument(LongOption),
160    WithArgument(fn(String) -> LongOption),
161}
162
163impl NonShellOptionConstructor {
164    fn from_name(name: &str) -> Option<Self> {
165        if "profile".starts_with(name) {
166            Some(Self::WithArgument(|path| LongOption::Profile { path }))
167        } else if "rcfile".starts_with(name) {
168            Some(Self::WithArgument(|path| LongOption::Rcfile { path }))
169        } else if "noprofile".starts_with(name) {
170            Some(Self::WithoutArgument(LongOption::NoProfile))
171        } else if "norcfile".starts_with(name) {
172            Some(Self::WithoutArgument(LongOption::NoRcfile))
173        } else if "help".starts_with(name) {
174            Some(Self::WithoutArgument(LongOption::Help))
175        } else if "version".starts_with(name) {
176            Some(Self::WithoutArgument(LongOption::Version))
177        } else {
178            None
179        }
180    }
181}
182
183/// Parses command line arguments.
184pub fn parse<I, S>(args: I) -> Result<Parse, Error>
185where
186    I: IntoIterator<Item = S>,
187    S: Into<String>,
188{
189    let mut args = args.into_iter().map(Into::into).peekable();
190    let mut result = Run::default();
191
192    // Below, we use `args.next_if(|_| true)` instead of `args.next()` to avoid
193    // consuming the `None` value that needs to be seen again.
194
195    // Parse the command name
196    if let Some(arg0) = args.next_if(|_| true) {
197        parse_arg0(&arg0, &mut result.options);
198        result.arg0 = arg0;
199    }
200
201    // Parse options
202    loop {
203        if let Some(option) = try_parse_short(&mut args, &mut result.options)? {
204            match option {
205                ShortOption::Shell => continue,
206                ShortOption::Version => return Ok(Parse::Version),
207            }
208        }
209
210        let Some(option) = try_parse_long(&mut args)? else {
211            break;
212        };
213        match option {
214            LongOption::Shell(option, state) => result.options.push((option, state)),
215            LongOption::Profile { path } => {
216                if result.work.profile != InitFile::None {
217                    result.work.profile = InitFile::File { path }
218                }
219            }
220            LongOption::NoProfile => result.work.profile = InitFile::None,
221            LongOption::Rcfile { path } => {
222                if result.work.rcfile != InitFile::None {
223                    result.work.rcfile = InitFile::File { path }
224                }
225            }
226            LongOption::NoRcfile => result.work.rcfile = InitFile::None,
227            LongOption::Help => return Ok(Parse::Help),
228            LongOption::Version => return Ok(Parse::Version),
229        }
230    }
231
232    args.next_if(|arg| arg == "-" || arg == "--");
233
234    // Parse operands
235    if result.options.contains(&(ShellOption::CmdLine, State::On)) {
236        if result.options.contains(&(ShellOption::Stdin, State::On)) {
237            return Err(Error::ConflictingSources);
238        }
239
240        let command = args.next_if(|_| true).ok_or(Error::MissingCommandString)?;
241        result.work.source = Source::String(command);
242        if let Some(name) = args.next_if(|_| true) {
243            result.arg0 = name;
244        }
245    } else if result.options.contains(&(ShellOption::Stdin, State::On)) {
246        result.work.source = Source::Stdin;
247    } else {
248        // No -c or -s
249        if let Some(operand) = args.next_if(|_| true) {
250            result.arg0.clone_from(&operand);
251            result.work.source = Source::File { path: operand };
252        }
253    }
254    result.positional_params = args.collect();
255
256    Ok(Parse::Run(result))
257}
258
259fn parse_arg0(arg0: &str, options: &mut Vec<(ShellOption, State)>) {
260    if arg0.starts_with('-') {
261        options.push((ShellOption::Login, State::On));
262    }
263    if arg0.rsplit('/').next().unwrap_or("") == "sh" {
264        options.push((ShellOption::PosixlyCorrect, State::On));
265    }
266}
267
268/// Parses the next argument as short options.
269///
270/// If the next argument is not a short option, returns `Ok(None)`.
271/// If the next argument is a short option, consumes it and returns `Ok(Some(_))`.
272/// The parsed options are added to `option_occurrences`.
273/// If the `-V` option is included, returns `Ok(Some(ShortOption::Version))`.
274fn try_parse_short<I: Iterator<Item = String>>(
275    args: &mut Peekable<I>,
276    option_occurrences: &mut Vec<(ShellOption, State)>,
277) -> Result<Option<ShortOption>, Error> {
278    let Some(mut arg) = args.next_if(|arg| is_short_option(arg)) else {
279        return Ok(None);
280    };
281
282    let mut chars = arg.chars();
283    let negate = match chars.next() {
284        Some('-') => false,
285        Some('+') => true,
286        _ => unreachable!(),
287    };
288
289    while let Some(c) = chars.next() {
290        if c == 'V' {
291            return if negate {
292                Err(Error::UnnegatableShortOption('V'))
293            } else {
294                Ok(Some(ShortOption::Version))
295            };
296        }
297        if c == 'o' {
298            let name = chars.as_str();
299            let name = if !name.is_empty() {
300                canonicalize(name)
301            } else {
302                let prev = arg;
303                arg = args.next().ok_or(Error::MissingOptionArgument(prev))?;
304                canonicalize(&arg)
305            };
306            match parse_long(&name) {
307                Ok((option, state)) => {
308                    option_occurrences.push((option, if negate { !state } else { state }));
309                    break;
310                }
311                Err(NoSuchOption) => return Err(Error::UnknownLongOption(name.into_owned())),
312                Err(Ambiguous) => return Err(Error::AmbiguousLongOption(name.into_owned())),
313            }
314        }
315
316        let (option, state) = parse_short(c).ok_or(Error::UnknownShortOption(c))?;
317        option_occurrences.push((option, if negate { !state } else { state }));
318    }
319
320    Ok(Some(ShortOption::Shell))
321}
322
323/// Tests if the given string is a short option.
324fn is_short_option(arg: &str) -> bool {
325    let mut chars = arg.chars();
326    let negate = match chars.next() {
327        Some('-') => false,
328        Some('+') => true,
329        _ => return false,
330    };
331    match chars.next() {
332        Some('-') if !negate => false,
333        Some('+') if negate => false,
334        Some(_) => true,
335        None => false,
336    }
337}
338
339/// Tries to parse and consume the next argument in `args` as a long option.
340fn try_parse_long<I: Iterator<Item = String>>(
341    args: &mut Peekable<I>,
342) -> Result<Option<LongOption>, Error> {
343    let Some(arg) = args.next_if(|arg| is_long_option(arg)) else {
344        return Ok(None);
345    };
346
347    let mut chars = arg.chars();
348    let negate = match chars.next() {
349        Some('-') => false,
350        Some('+') => true,
351        _ => unreachable!(),
352    };
353
354    // Skip the second `-` or `+`
355    chars.next();
356
357    let chars = chars.as_str();
358
359    // Parse non-shell options
360    let (name, value) = match chars.split_once('=') {
361        Some((name, value)) => (name, Some(value)),
362        None => (chars, None),
363    };
364    let non_shell_option = NonShellOptionConstructor::from_name(name);
365
366    // Parse shell options
367    let shell_option = parse_long(&canonicalize(chars));
368
369    // Check if the result is unique and return the final result
370    match (non_shell_option, shell_option) {
371        (_, Err(Ambiguous)) | (Some(_), Ok(_)) => Err(Error::AmbiguousLongOption(arg)),
372
373        (None, Err(NoSuchOption)) => Err(Error::UnknownLongOption(arg)),
374
375        (Some(_), Err(NoSuchOption)) if negate => Err(Error::UnnegatableLongOption(arg)),
376
377        (Some(NonShellOptionConstructor::WithoutArgument(option)), Err(NoSuchOption)) => {
378            if value.is_none() {
379                Ok(Some(option))
380            } else {
381                Err(Error::UnexpectedOptionArgument(arg))
382            }
383        }
384
385        (Some(NonShellOptionConstructor::WithArgument(ctor)), Err(NoSuchOption)) => {
386            let value = match value {
387                Some(value) => value.to_owned(),
388                None => match args.next() {
389                    Some(next_arg) => next_arg,
390                    None => return Err(Error::MissingOptionArgument(arg)),
391                },
392            };
393            Ok(Some(ctor(value)))
394        }
395
396        (None, Ok((option, state))) if negate => Ok(Some(LongOption::Shell(option, !state))),
397        (None, Ok((option, state))) => Ok(Some(LongOption::Shell(option, state))),
398    }
399}
400
401/// Tests if the given string is a long option.
402fn is_long_option(arg: &str) -> bool {
403    if let Some(name) = arg.strip_prefix("--") {
404        !name.is_empty()
405    } else {
406        arg.starts_with("++")
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use assert_matches::assert_matches;
414
415    fn parse<I, S>(args: I) -> Result<Parse, Error>
416    where
417        I: IntoIterator<Item = S>,
418        S: Into<String>,
419    {
420        use fuzed_iterator::IteratorExt;
421        super::parse(args.into_iter().fuze())
422    }
423
424    #[test]
425    fn no_arguments() {
426        assert_eq!(parse([] as [&str; 0]), Ok(Parse::Run(Run::default())));
427    }
428
429    #[test]
430    fn arg0_only() {
431        assert_eq!(
432            parse(["yash"]),
433            Ok(Parse::Run(Run {
434                arg0: "yash".to_string(),
435                ..Run::default()
436            })),
437        );
438    }
439
440    #[test]
441    fn run_file() {
442        // Without positional parameters
443        assert_eq!(
444            parse(["yash", "my-script"]),
445            Ok(Parse::Run(Run {
446                work: Work {
447                    source: Source::File {
448                        path: "my-script".to_string()
449                    },
450                    ..Work::default()
451                },
452                arg0: "my-script".to_string(),
453                ..Run::default()
454            })),
455        );
456
457        // With positional parameters
458        assert_eq!(
459            parse(["yash", "path/to/script", "-option", "foo", "bar"]),
460            Ok(Parse::Run(Run {
461                work: Work {
462                    source: Source::File {
463                        path: "path/to/script".to_string()
464                    },
465                    ..Work::default()
466                },
467                arg0: "path/to/script".to_string(),
468                positional_params: vec![
469                    "-option".to_string(),
470                    "foo".to_string(),
471                    "bar".to_string()
472                ],
473                ..Run::default()
474            })),
475        );
476    }
477
478    #[test]
479    fn run_string() {
480        // Without command name or positional parameters
481        assert_eq!(
482            parse(["yash", "-c", "echo"]),
483            Ok(Parse::Run(Run {
484                work: Work {
485                    source: Source::String("echo".to_string()),
486                    ..Work::default()
487                },
488                options: vec![(ShellOption::CmdLine, State::On)],
489                arg0: "yash".to_string(),
490                ..Run::default()
491            })),
492        );
493
494        // With command name but no positional parameters
495        assert_eq!(
496            parse(["yash", "-c", "echo", "name"]),
497            Ok(Parse::Run(Run {
498                work: Work {
499                    source: Source::String("echo".to_string()),
500                    ..Work::default()
501                },
502                options: vec![(ShellOption::CmdLine, State::On)],
503                arg0: "name".to_string(),
504                ..Run::default()
505            })),
506        );
507
508        // With command name and positional parameters
509        assert_eq!(
510            parse(["yash", "-c", "echo", "name", "foo", "bar"]),
511            Ok(Parse::Run(Run {
512                work: Work {
513                    source: Source::String("echo".to_string()),
514                    ..Work::default()
515                },
516                options: vec![(ShellOption::CmdLine, State::On)],
517                arg0: "name".to_string(),
518                positional_params: vec!["foo".to_string(), "bar".to_string()],
519            }))
520        );
521
522        // long option
523        assert_eq!(
524            parse(["yash", "--cmd-line", "echo"]),
525            Ok(Parse::Run(Run {
526                work: Work {
527                    source: Source::String("echo".to_string()),
528                    ..Work::default()
529                },
530                options: vec![(ShellOption::CmdLine, State::On)],
531                arg0: "yash".to_string(),
532                ..Run::default()
533            })),
534        );
535    }
536
537    #[test]
538    fn missing_command_string() {
539        assert_eq!(parse(["yash", "-c"]), Err(Error::MissingCommandString));
540    }
541
542    #[test]
543    fn run_stdin() {
544        // Without positional parameters
545        assert_eq!(
546            parse(["yash", "-s"]),
547            Ok(Parse::Run(Run {
548                work: Work {
549                    source: Source::Stdin,
550                    ..Work::default()
551                },
552                options: vec![(ShellOption::Stdin, State::On)],
553                arg0: "yash".to_string(),
554                ..Run::default()
555            })),
556        );
557
558        // With positional parameters
559        assert_eq!(
560            parse(["yash", "-s", "foo", "bar", "-baz"]),
561            Ok(Parse::Run(Run {
562                work: Work {
563                    source: Source::Stdin,
564                    ..Work::default()
565                },
566                options: vec![(ShellOption::Stdin, State::On)],
567                arg0: "yash".to_string(),
568                positional_params: vec!["foo".to_string(), "bar".to_string(), "-baz".to_string()],
569            })),
570        );
571
572        // long option
573        assert_eq!(
574            parse(["yash", "--stdin"]),
575            Ok(Parse::Run(Run {
576                work: Work {
577                    source: Source::Stdin,
578                    ..Work::default()
579                },
580                options: vec![(ShellOption::Stdin, State::On)],
581                arg0: "yash".to_string(),
582                ..Run::default()
583            })),
584        );
585    }
586
587    #[test]
588    fn conflicting_sources() {
589        assert_eq!(parse(["yash", "-cs"]), Err(Error::ConflictingSources));
590    }
591
592    #[test]
593    fn short_options() {
594        // Single short option
595        assert_eq!(
596            parse(["yash", "-a"]),
597            Ok(Parse::Run(Run {
598                options: vec![(ShellOption::AllExport, State::On)],
599                arg0: "yash".to_string(),
600                ..Run::default()
601            })),
602        );
603        assert_eq!(
604            parse(["yash", "-n"]),
605            Ok(Parse::Run(Run {
606                options: vec![(ShellOption::Exec, State::Off)],
607                arg0: "yash".to_string(),
608                ..Run::default()
609            })),
610        );
611
612        // Combined short options
613        assert_eq!(
614            parse(["yash", "-an"]),
615            Ok(Parse::Run(Run {
616                options: vec![
617                    (ShellOption::AllExport, State::On),
618                    (ShellOption::Exec, State::Off)
619                ],
620                arg0: "yash".to_string(),
621                ..Run::default()
622            })),
623        );
624
625        // Many short options
626        assert_eq!(
627            parse(["yash", "-a", "-nu", "-x"]),
628            Ok(Parse::Run(Run {
629                options: vec![
630                    (ShellOption::AllExport, State::On),
631                    (ShellOption::Exec, State::Off),
632                    (ShellOption::Unset, State::Off),
633                    (ShellOption::XTrace, State::On),
634                ],
635                arg0: "yash".to_string(),
636                ..Run::default()
637            })),
638        );
639    }
640
641    #[test]
642    fn negated_short_options() {
643        // Single short option
644        assert_eq!(
645            parse(["yash", "+a"]),
646            Ok(Parse::Run(Run {
647                options: vec![(ShellOption::AllExport, State::Off)],
648                arg0: "yash".to_string(),
649                ..Run::default()
650            })),
651        );
652        assert_eq!(
653            parse(["yash", "+n"]),
654            Ok(Parse::Run(Run {
655                options: vec![(ShellOption::Exec, State::On)],
656                arg0: "yash".to_string(),
657                ..Run::default()
658            })),
659        );
660
661        // Combined short options
662        assert_eq!(
663            parse(["yash", "+an"]),
664            Ok(Parse::Run(Run {
665                options: vec![
666                    (ShellOption::AllExport, State::Off),
667                    (ShellOption::Exec, State::On)
668                ],
669                arg0: "yash".to_string(),
670                ..Run::default()
671            })),
672        );
673
674        // Many short options
675        assert_eq!(
676            parse(["yash", "+a", "+ns", "+x"]),
677            Ok(Parse::Run(Run {
678                options: vec![
679                    (ShellOption::AllExport, State::Off),
680                    (ShellOption::Exec, State::On),
681                    (ShellOption::Stdin, State::Off),
682                    (ShellOption::XTrace, State::Off),
683                ],
684                arg0: "yash".to_string(),
685                ..Run::default()
686            })),
687        );
688    }
689
690    #[test]
691    fn o_options() {
692        // Adjoined o options
693        assert_eq!(
694            parse(["yash", "-oallexport"]),
695            Ok(Parse::Run(Run {
696                options: vec![(ShellOption::AllExport, State::On)],
697                arg0: "yash".to_string(),
698                ..Run::default()
699            })),
700        );
701
702        // Separate o options
703        assert_eq!(
704            parse(["yash", "-o", "allexport"]),
705            Ok(Parse::Run(Run {
706                options: vec![(ShellOption::AllExport, State::On)],
707                arg0: "yash".to_string(),
708                ..Run::default()
709            })),
710        );
711
712        // Non-canonical o options
713        assert_eq!(
714            parse(["yash", "-o", "all-Export", "-o StD+in_"]),
715            Ok(Parse::Run(Run {
716                options: vec![
717                    (ShellOption::AllExport, State::On),
718                    (ShellOption::Stdin, State::On),
719                ],
720                arg0: "yash".to_string(),
721                ..Run::default()
722            })),
723        );
724    }
725
726    #[test]
727    fn negated_o_options() {
728        // Adjoined o options
729        assert_eq!(
730            parse(["yash", "+oallexport"]),
731            Ok(Parse::Run(Run {
732                options: vec![(ShellOption::AllExport, State::Off)],
733                arg0: "yash".to_string(),
734                ..Run::default()
735            })),
736        );
737
738        // Separate o options
739        assert_eq!(
740            parse(["yash", "+o", "allexport"]),
741            Ok(Parse::Run(Run {
742                options: vec![(ShellOption::AllExport, State::Off)],
743                arg0: "yash".to_string(),
744                ..Run::default()
745            })),
746        );
747
748        // Non-canonical o options
749        assert_eq!(
750            parse(["yash", "+o", "all-Export", "+o no_exec"]),
751            Ok(Parse::Run(Run {
752                options: vec![
753                    (ShellOption::AllExport, State::Off),
754                    (ShellOption::Exec, State::On),
755                ],
756                arg0: "yash".to_string(),
757                ..Run::default()
758            })),
759        );
760    }
761
762    #[test]
763    fn long_options() {
764        assert_eq!(
765            parse(["yash", "--all-export"]),
766            Ok(Parse::Run(Run {
767                options: vec![(ShellOption::AllExport, State::On)],
768                arg0: "yash".to_string(),
769                ..Run::default()
770            })),
771        );
772        assert_eq!(
773            parse(["yash", "--all-export", "--no*un=set"]),
774            Ok(Parse::Run(Run {
775                options: vec![
776                    (ShellOption::AllExport, State::On),
777                    (ShellOption::Unset, State::Off),
778                ],
779                arg0: "yash".to_string(),
780                ..Run::default()
781            })),
782        );
783    }
784
785    #[test]
786    fn negated_long_options() {
787        assert_eq!(
788            parse(["yash", "++all-export"]),
789            Ok(Parse::Run(Run {
790                options: vec![(ShellOption::AllExport, State::Off)],
791                arg0: "yash".to_string(),
792                ..Run::default()
793            })),
794        );
795        assert_eq!(
796            parse(["yash", "++all+export", "++no*un-set"]),
797            Ok(Parse::Run(Run {
798                options: vec![
799                    (ShellOption::AllExport, State::Off),
800                    (ShellOption::Unset, State::On),
801                ],
802                arg0: "yash".to_string(),
803                ..Run::default()
804            })),
805        );
806    }
807
808    #[test]
809    fn profile_option() {
810        // Separate argument
811        assert_eq!(
812            parse(["yash", "--profile", "my/file"]),
813            Ok(Parse::Run(Run {
814                work: Work {
815                    profile: InitFile::File {
816                        path: "my/file".to_string(),
817                    },
818                    ..Work::default()
819                },
820                arg0: "yash".to_string(),
821                ..Run::default()
822            })),
823        );
824
825        // Adjoined argument
826        assert_eq!(
827            parse(["yash", "--profile=my/file"]),
828            Ok(Parse::Run(Run {
829                work: Work {
830                    profile: InitFile::File {
831                        path: "my/file".to_string(),
832                    },
833                    ..Work::default()
834                },
835                arg0: "yash".to_string(),
836                ..Run::default()
837            })),
838        );
839
840        // Abbreviated option name
841        assert_eq!(
842            parse(["yash", "--pr=ofile"]),
843            Ok(Parse::Run(Run {
844                work: Work {
845                    profile: InitFile::File {
846                        path: "ofile".to_string(),
847                    },
848                    ..Work::default()
849                },
850                arg0: "yash".to_string(),
851                ..Run::default()
852            })),
853        );
854    }
855
856    #[test]
857    fn missing_profile_option_argument() {
858        assert_eq!(
859            parse(["yash", "--profile"]),
860            Err(Error::MissingOptionArgument("--profile".to_string())),
861        );
862    }
863
864    #[test]
865    fn noprofile_option() {
866        let expected = Ok(Parse::Run(Run {
867            work: Work {
868                profile: InitFile::None,
869                ..Work::default()
870            },
871            arg0: "yash".to_string(),
872            ..Run::default()
873        }));
874        assert_eq!(parse(["yash", "--noprofile"]), expected);
875
876        // noprofile option wins over profile option
877        assert_eq!(parse(["yash", "--profile=file", "--noprofile"]), expected);
878        assert_eq!(parse(["yash", "--noprofile", "--profile=file"]), expected);
879    }
880
881    #[test]
882    fn unexpected_noprofile_option_argument() {
883        assert_eq!(
884            parse(["yash", "--noprofile=x"]),
885            Err(Error::UnexpectedOptionArgument("--noprofile=x".to_string())),
886        );
887    }
888
889    #[test]
890    fn rcfile_option() {
891        // Separate argument
892        assert_eq!(
893            parse(["yash", "--rcfile", "my/file"]),
894            Ok(Parse::Run(Run {
895                work: Work {
896                    rcfile: InitFile::File {
897                        path: "my/file".to_string(),
898                    },
899                    ..Work::default()
900                },
901                arg0: "yash".to_string(),
902                ..Run::default()
903            })),
904        );
905
906        // Adjoined argument
907        assert_eq!(
908            parse(["yash", "--rcfile=my/file"]),
909            Ok(Parse::Run(Run {
910                work: Work {
911                    rcfile: InitFile::File {
912                        path: "my/file".to_string(),
913                    },
914                    ..Work::default()
915                },
916                arg0: "yash".to_string(),
917                ..Run::default()
918            })),
919        );
920
921        // Abbreviated option name
922        assert_eq!(
923            parse(["yash", "--rc=file"]),
924            Ok(Parse::Run(Run {
925                work: Work {
926                    rcfile: InitFile::File {
927                        path: "file".to_string(),
928                    },
929                    ..Work::default()
930                },
931                arg0: "yash".to_string(),
932                ..Run::default()
933            })),
934        );
935    }
936
937    #[test]
938    fn missing_rcfile_option_argument() {
939        assert_eq!(
940            parse(["yash", "--rcfile"]),
941            Err(Error::MissingOptionArgument("--rcfile".to_string())),
942        );
943    }
944
945    #[test]
946    fn norcfile_option() {
947        let expected = Ok(Parse::Run(Run {
948            work: Work {
949                rcfile: InitFile::None,
950                ..Work::default()
951            },
952            arg0: "yash".to_string(),
953            ..Run::default()
954        }));
955        assert_eq!(parse(["yash", "--norcfile"]), expected);
956
957        // norcfile option wins over rcfile option
958        assert_eq!(parse(["yash", "--rcfile=file", "--norcfile"]), expected);
959        assert_eq!(parse(["yash", "--norcfile", "--rcfile=file"]), expected);
960    }
961
962    #[test]
963    fn option_combinations() {
964        assert_eq!(
965            parse(["yash", "-a", "--err-exit", "-u"]),
966            Ok(Parse::Run(Run {
967                options: vec![
968                    (ShellOption::AllExport, State::On),
969                    (ShellOption::ErrExit, State::On),
970                    (ShellOption::Unset, State::Off),
971                ],
972                arg0: "yash".to_string(),
973                ..Run::default()
974            })),
975        );
976
977        assert_eq!(
978            parse(["yash", "-xo", "noclobber", "-il"]),
979            Ok(Parse::Run(Run {
980                options: vec![
981                    (ShellOption::XTrace, State::On),
982                    (ShellOption::Clobber, State::Off),
983                    (ShellOption::Interactive, State::On),
984                    (ShellOption::Login, State::On),
985                ],
986                arg0: "yash".to_string(),
987                ..Run::default()
988            })),
989        );
990
991        assert_eq!(
992            parse(["yash", "--all", "-f", "--posix"]),
993            Ok(Parse::Run(Run {
994                options: vec![
995                    (ShellOption::AllExport, State::On),
996                    (ShellOption::Glob, State::Off),
997                    (ShellOption::PosixlyCorrect, State::On),
998                ],
999                arg0: "yash".to_string(),
1000                ..Run::default()
1001            })),
1002        );
1003    }
1004
1005    #[test]
1006    fn double_hyphen_separator_and_operands() {
1007        assert_eq!(
1008            parse(["yash", "--"]),
1009            Ok(Parse::Run(Run {
1010                arg0: "yash".to_string(),
1011                ..Default::default()
1012            })),
1013        );
1014
1015        assert_eq!(
1016            parse(["yash", "-a", "--", "file", "arg"]),
1017            Ok(Parse::Run(Run {
1018                work: Work {
1019                    source: Source::File {
1020                        path: "file".to_string()
1021                    },
1022                    ..Work::default()
1023                },
1024                options: vec![(ShellOption::AllExport, State::On)],
1025                arg0: "file".to_string(),
1026                positional_params: vec!["arg".to_string()],
1027            })),
1028        );
1029
1030        assert_eq!(
1031            parse(["yash", "-a", "--", "--", "arg"]),
1032            Ok(Parse::Run(Run {
1033                work: Work {
1034                    source: Source::File {
1035                        path: "--".to_string()
1036                    },
1037                    ..Work::default()
1038                },
1039                options: vec![(ShellOption::AllExport, State::On)],
1040                arg0: "--".to_string(),
1041                positional_params: vec!["arg".to_string()],
1042            })),
1043        );
1044    }
1045
1046    #[test]
1047    fn single_hyphen_separator_and_operands() {
1048        assert_eq!(
1049            parse(["yash", "-"]),
1050            Ok(Parse::Run(Run {
1051                arg0: "yash".to_string(),
1052                ..Default::default()
1053            })),
1054        );
1055
1056        assert_eq!(
1057            parse(["yash", "-a", "-", "file", "arg"]),
1058            Ok(Parse::Run(Run {
1059                work: Work {
1060                    source: Source::File {
1061                        path: "file".to_string()
1062                    },
1063                    ..Work::default()
1064                },
1065                options: vec![(ShellOption::AllExport, State::On)],
1066                arg0: "file".to_string(),
1067                positional_params: vec!["arg".to_string()],
1068            })),
1069        );
1070
1071        assert_eq!(
1072            parse(["yash", "-a", "-", "-", "arg"]),
1073            Ok(Parse::Run(Run {
1074                work: Work {
1075                    source: Source::File {
1076                        path: "-".to_string()
1077                    },
1078                    ..Work::default()
1079                },
1080                options: vec![(ShellOption::AllExport, State::On)],
1081                arg0: "-".to_string(),
1082                positional_params: vec!["arg".to_string()],
1083            })),
1084        );
1085    }
1086
1087    #[test]
1088    fn option_after_operand() {
1089        assert_eq!(
1090            parse(["yash", "-a", "file", "-e"]),
1091            Ok(Parse::Run(Run {
1092                work: Work {
1093                    source: Source::File {
1094                        path: "file".to_string()
1095                    },
1096                    ..Work::default()
1097                },
1098                options: vec![(ShellOption::AllExport, State::On)],
1099                arg0: "file".to_string(),
1100                positional_params: vec!["-e".to_string()],
1101            })),
1102        );
1103    }
1104
1105    #[test]
1106    fn leading_hyphen_in_arg0_makes_login_shell() {
1107        assert_eq!(
1108            parse(["-yash"]),
1109            Ok(Parse::Run(Run {
1110                options: vec![(ShellOption::Login, State::On)],
1111                arg0: "-yash".to_string(),
1112                ..Run::default()
1113            })),
1114        );
1115
1116        assert_matches!(
1117            parse(["-/bin/sh"]),
1118            Ok(Parse::Run(run)) => {
1119                assert!(run.options.contains(&(ShellOption::Login, State::On)));
1120            }
1121        );
1122    }
1123
1124    #[test]
1125    fn command_name_sh_enables_posix_mode() {
1126        assert_eq!(
1127            parse(["sh"]),
1128            Ok(Parse::Run(Run {
1129                options: vec![(ShellOption::PosixlyCorrect, State::On)],
1130                arg0: "sh".to_string(),
1131                ..Run::default()
1132            })),
1133        );
1134        assert_eq!(
1135            parse(["/usr/bin/sh"]),
1136            Ok(Parse::Run(Run {
1137                options: vec![(ShellOption::PosixlyCorrect, State::On)],
1138                arg0: "/usr/bin/sh".to_string(),
1139                ..Run::default()
1140            })),
1141        );
1142
1143        assert_matches!(
1144            parse(["-/bin/sh"]),
1145            Ok(Parse::Run(run)) => {
1146                assert!(run.options.contains(&(ShellOption::PosixlyCorrect, State::On)));
1147            }
1148        );
1149    }
1150
1151    #[test]
1152    fn help_option() {
1153        assert_eq!(parse(["yash", "--help"]), Ok(Parse::Help));
1154        assert_eq!(parse(["yash", "-a", "--help", "file"]), Ok(Parse::Help));
1155    }
1156
1157    #[test]
1158    fn version_option() {
1159        assert_eq!(parse(["yash", "-V"]), Ok(Parse::Version));
1160        assert_eq!(parse(["yash", "-aV", "x"]), Ok(Parse::Version));
1161
1162        assert_eq!(parse(["yash", "--version"]), Ok(Parse::Version));
1163        assert_eq!(parse(["yash", "-a", "--version", "x"]), Ok(Parse::Version));
1164    }
1165
1166    #[test]
1167    fn ambiguous_long_option() {
1168        assert_eq!(
1169            parse(["yash", "--no"]),
1170            Err(Error::AmbiguousLongOption("--no".to_string())),
1171        );
1172        assert_eq!(
1173            parse(["yash", "--p"]),
1174            Err(Error::AmbiguousLongOption("--p".to_string())),
1175        );
1176        assert_eq!(
1177            parse(["yash", "--ver=bose"]),
1178            Err(Error::AmbiguousLongOption("--ver=bose".to_string())),
1179        );
1180    }
1181
1182    #[test]
1183    fn non_existing_option() {
1184        assert_eq!(
1185            parse(["yash", "-x", "-y"]),
1186            Err(Error::UnknownShortOption('y')),
1187        );
1188        assert_eq!(parse(["yash", "-CDf"]), Err(Error::UnknownShortOption('D')),);
1189
1190        assert_eq!(
1191            parse(["yash", "--unexisting"]),
1192            Err(Error::UnknownLongOption("--unexisting".to_string())),
1193        );
1194        assert_eq!(
1195            parse(["yash", "--no+un=existing"]),
1196            Err(Error::UnknownLongOption("--no+un=existing".to_string())),
1197        );
1198    }
1199
1200    #[test]
1201    fn unnegatable_short_option() {
1202        assert_eq!(
1203            parse(["yash", "+V"]),
1204            Err(Error::UnnegatableShortOption('V')),
1205        );
1206    }
1207
1208    #[test]
1209    fn unnegatable_long_option() {
1210        assert_eq!(
1211            parse(["yash", "++profile"]),
1212            Err(Error::UnnegatableLongOption("++profile".to_string())),
1213        );
1214        assert_eq!(
1215            parse(["yash", "++vers=ion"]),
1216            Err(Error::UnnegatableLongOption("++vers=ion".to_string())),
1217        );
1218    }
1219}