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