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(
165 "option argument not separated from the option name in `{argument}`; use `{separated_spelling}` instead"
166 )]
167 UnseparatedOptionArgument {
168 argument: String,
170 separated_spelling: String,
172 },
173}
174
175fn suggestion(spelling: &Option<String>) -> String {
177 match spelling {
178 Some(spelling) => format!("; use `{spelling}` instead"),
179 None => String::new(),
180 }
181}
182
183fn is_portable_long_name(name: &str, option: ShellOption, state: State) -> bool {
188 if option == ShellOption::Portable {
189 name == "portable"
193 } else {
194 option.portable_long_name() == Some((name, state))
195 }
196}
197
198fn 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219enum ShortOption {
220 Shell,
222 Version,
224}
225
226#[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#[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
265pub 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 let mut portable = State::Off;
277
278 if let Some(arg0) = args.next_if(|_| true) {
283 parse_arg0(&arg0, &mut result.options);
284 result.arg0 = arg0;
285 }
286
287 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 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 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
354fn 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 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 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 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
454fn 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
470fn 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 chars.next();
495
496 let chars = chars.as_str();
497
498 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 let shell_option = parse_long(&canonicalize(chars));
507
508 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
556fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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", "--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 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 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 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", "--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 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 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}