Skip to main content

yash_builtin/set/
syntax.rs

1// This file is part of yash, an extended POSIX shell.
2// Copyright (C) 2022 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 set built-in
18
19use super::Command;
20use std::iter::Peekable;
21use thiserror::Error;
22use yash_env::option::FromStrError::*;
23use yash_env::option::State;
24use yash_env::option::canonicalize;
25use yash_env::option::parse_long;
26use yash_env::option::parse_short;
27use yash_env::semantics::Field;
28use yash_env::source::pretty::Snippet;
29use yash_env::source::pretty::{Report, ReportType};
30
31/// Error in command line parsing
32#[derive(Clone, Debug, Eq, Error, PartialEq)]
33pub enum Error {
34    /// Short option that is not defined in the option specs
35    #[error("unknown option {0:?}")]
36    UnknownShortOption(char, Field),
37
38    /// Long option that is not defined in the option specs
39    #[error("unknown option {:?}", .0.value)]
40    UnknownLongOption(Field),
41
42    /// Long option that matches the prefix of more than one option name.
43    #[error("ambiguous option name {:?}", .0.value)]
44    AmbiguousLongOption(Field),
45
46    /// `-o` or `+o` used without an option name
47    #[error("option {:?} missing an argument", .0.value)]
48    MissingOptionArgument(Field),
49
50    /// Short option that is not modifiable by the set built-in
51    #[error("option {0:?} not modifiable by the set built-in")]
52    UnmodifiableShortOption(char, Field),
53
54    /// Long option that is not modifiable by the set built-in
55    #[error("option {:?} not modifiable by the set built-in", .0.value)]
56    UnmodifiableLongOption(Field),
57}
58
59impl Error {
60    /// Returns a reference to the field in which the error occurred.
61    pub fn field(&self) -> &Field {
62        match self {
63            Error::UnknownShortOption(_char, field) => field,
64            Error::UnknownLongOption(field) => field,
65            Error::AmbiguousLongOption(field) => field,
66            Error::MissingOptionArgument(field) => field,
67            Error::UnmodifiableShortOption(_char, field) => field,
68            Error::UnmodifiableLongOption(field) => field,
69        }
70    }
71
72    /// Converts this error to a report.
73    #[must_use]
74    pub fn to_report(&self) -> Report<'_> {
75        let mut report = Report::new();
76        report.r#type = ReportType::Error;
77        report.title = self.to_string().into();
78
79        let field = self.field();
80        report.snippets = Snippet::with_primary_span(&field.origin, field.value.as_str().into());
81
82        report
83    }
84}
85
86impl<'a> From<&'a Error> for Report<'a> {
87    #[inline]
88    fn from(value: &'a Error) -> Self {
89        value.to_report()
90    }
91}
92
93/// Tries to parse the next field in `args`.
94///
95/// Returns `Ok(true)` if the next field contained a short option, in which case
96/// the parsed field is consumed from the iterator.
97fn try_parse_short<I: Iterator<Item = Field>>(
98    args: &mut Peekable<I>,
99    option_occurrences: &mut Vec<(yash_env::option::Option, State)>,
100) -> Result<bool, Error> {
101    let field = match args.peek() {
102        Some(field) => field,
103        None => return Ok(false),
104    };
105
106    let mut chars = field.value.chars();
107    let negate = match chars.next() {
108        Some('-') => false,
109        Some('+') => true,
110        _ => return Ok(false),
111    };
112    match chars.next() {
113        Some('-') if !negate => return Ok(false),
114        Some('+') if negate => return Ok(false),
115        None => return Ok(false),
116        _ => (),
117    }
118
119    let mut field = args.next().unwrap();
120    let mut chars = field.value.chars();
121    chars.next().unwrap();
122    while let Some(c) = chars.next() {
123        if c == 'o' {
124            let name = chars.as_str();
125            let name = if !name.is_empty() {
126                canonicalize(name)
127            } else {
128                let prev = field;
129                field = args.next().ok_or(Error::MissingOptionArgument(prev))?;
130                canonicalize(&field.value)
131            };
132            match parse_long(&name) {
133                Ok((option, state)) if option.is_modifiable() => {
134                    option_occurrences.push((option, if negate { !state } else { state }));
135                    break;
136                }
137                Ok(_) => return Err(Error::UnmodifiableLongOption(field)),
138                Err(NoSuchOption) => return Err(Error::UnknownLongOption(field)),
139                Err(Ambiguous) => return Err(Error::AmbiguousLongOption(field)),
140            }
141        }
142
143        match parse_short(c) {
144            Some((option, state)) if option.is_modifiable() => {
145                option_occurrences.push((option, if negate { !state } else { state }))
146            }
147            Some(_) => return Err(Error::UnmodifiableShortOption(c, field)),
148            None => return Err(Error::UnknownShortOption(c, field)),
149        }
150    }
151    Ok(true)
152}
153
154/// Tries to parse and consume the next field in `args`.
155fn try_parse_long<I: Iterator<Item = Field>>(
156    args: &mut Peekable<I>,
157) -> Result<std::option::Option<(yash_env::option::Option, State)>, Error> {
158    let field = match args.peek() {
159        Some(field) => field,
160        None => return Ok(None),
161    };
162
163    let (name, negate) = if let Some(name) = field.value.strip_prefix("--") {
164        if name.is_empty() {
165            return Ok(None);
166        }
167        (name, false)
168    } else if let Some(name) = field.value.strip_prefix("++") {
169        (name, true)
170    } else {
171        return Ok(None);
172    };
173
174    let name = canonicalize(name);
175    let result = parse_long(&name);
176    let field = args.next().unwrap();
177    match result {
178        Ok((option, state)) if option.is_modifiable() => {
179            Ok(Some((option, if negate { !state } else { state })))
180        }
181        Ok(_) => Err(Error::UnmodifiableLongOption(field)),
182        Err(NoSuchOption) => Err(Error::UnknownLongOption(field)),
183        Err(Ambiguous) => Err(Error::AmbiguousLongOption(field)),
184    }
185}
186
187/// Parses command line arguments.
188pub fn parse(args: Vec<Field>) -> Result<Command, Error> {
189    match args.len() {
190        0 => return Ok(Command::PrintVariables),
191        1 => match args[0].value.as_str() {
192            "-o" => return Ok(Command::PrintOptionsHumanReadable),
193            "+o" => return Ok(Command::PrintOptionsMachineReadable),
194            _ => (),
195        },
196        _ => (),
197    }
198
199    let mut args = args.into_iter().peekable();
200    let mut options = Vec::new();
201    loop {
202        if try_parse_short(&mut args, &mut options)? {
203            continue;
204        }
205        if let Some(result) = try_parse_long(&mut args)? {
206            options.push(result);
207        } else {
208            break;
209        }
210    }
211
212    let separated = match args.peek().map(|arg| arg.value.as_str()) {
213        Some("--" | "-") => {
214            drop(args.next());
215            true
216        }
217        _ => false,
218    };
219
220    let positional_params = (separated || args.peek().is_some()).then(|| args.collect());
221
222    Ok(Command::Modify {
223        options,
224        positional_params,
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use assert_matches::assert_matches;
232    use yash_env::option::Option::*;
233    use yash_env::option::State::*;
234
235    #[test]
236    fn simple_cases() {
237        assert_eq!(parse(vec![]), Ok(Command::PrintVariables));
238        assert_eq!(
239            parse(Field::dummies(["-o"])),
240            Ok(Command::PrintOptionsHumanReadable)
241        );
242        assert_eq!(
243            parse(Field::dummies(["+o"])),
244            Ok(Command::PrintOptionsMachineReadable)
245        );
246    }
247
248    #[test]
249    fn positional_params_only() {
250        assert_matches!(
251            parse(Field::dummies(["foo"])),
252            Ok(Command::Modify {
253                options,
254                positional_params
255            }) => {
256                assert_eq!(options, []);
257                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
258                    assert_eq!(first.value, "foo");
259                });
260            }
261        );
262
263        assert_matches!(
264            parse(Field::dummies([""])),
265            Ok(Command::Modify {
266                options,
267                positional_params
268            }) => {
269                assert_eq!(options, []);
270                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
271                    assert_eq!(first.value, "");
272                });
273            }
274        );
275
276        assert_matches!(
277            parse(Field::dummies(["a", "b", "c"])),
278            Ok(Command::Modify {
279                options,
280                positional_params
281            }) => {
282                assert_eq!(options, []);
283                assert_matches!(positional_params.unwrap().as_slice(), [first, second, third] => {
284                    assert_eq!(first.value, "a");
285                    assert_eq!(second.value, "b");
286                    assert_eq!(third.value, "c");
287                });
288            }
289        );
290    }
291
292    #[test]
293    fn double_hyphen_separator_and_positional_params() {
294        assert_matches!(
295            parse(Field::dummies(["--"])),
296            Ok(Command::Modify {
297                options,
298                positional_params
299            }) => {
300                assert_eq!(options, []);
301                assert_eq!(positional_params.unwrap().as_slice(), []);
302            }
303        );
304
305        assert_matches!(
306            parse(Field::dummies(["--", "foo", "bar"])),
307            Ok(Command::Modify {
308                options,
309                positional_params
310            }) => {
311                assert_eq!(options, []);
312                assert_matches!(positional_params.unwrap().as_slice(), [first, second] => {
313                    assert_eq!(first.value, "foo");
314                    assert_eq!(second.value, "bar");
315                });
316            }
317        );
318
319        assert_matches!(
320            parse(Field::dummies(["--", "--"])),
321            Ok(Command::Modify {
322                options,
323                positional_params
324            }) => {
325                assert_eq!(options, []);
326                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
327                    assert_eq!(first.value, "--");
328                });
329            }
330        );
331
332        assert_matches!(
333            parse(Field::dummies(["--", "-"])),
334            Ok(Command::Modify {
335                options,
336                positional_params
337            }) => {
338                assert_eq!(options, []);
339                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
340                    assert_eq!(first.value, "-");
341                });
342            }
343        );
344
345        assert_matches!(
346            parse(Field::dummies(["--", "-a"])),
347            Ok(Command::Modify {
348                options,
349                positional_params
350            }) => {
351                assert_eq!(options, []);
352                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
353                    assert_eq!(first.value, "-a");
354                });
355            }
356        );
357    }
358
359    #[test]
360    fn single_hyphen_separator_and_positional_params() {
361        assert_matches!(
362            parse(Field::dummies(["-"])),
363            Ok(Command::Modify {
364                options,
365                positional_params
366            }) => {
367                assert_eq!(options, []);
368                assert_eq!(positional_params.unwrap().as_slice(), []);
369            }
370        );
371
372        assert_matches!(
373            parse(Field::dummies(["-", "foo", "bar"])),
374            Ok(Command::Modify {
375                options,
376                positional_params
377            }) => {
378                assert_eq!(options, []);
379                assert_matches!(positional_params.unwrap().as_slice(), [first, second] => {
380                    assert_eq!(first.value, "foo");
381                    assert_eq!(second.value, "bar");
382                });
383            }
384        );
385
386        assert_matches!(
387            parse(Field::dummies(["-", "-"])),
388            Ok(Command::Modify {
389                options,
390                positional_params
391            }) => {
392                assert_eq!(options, []);
393                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
394                    assert_eq!(first.value, "-");
395                });
396            }
397        );
398
399        assert_matches!(
400            parse(Field::dummies(["-", "--"])),
401            Ok(Command::Modify {
402                options,
403                positional_params
404            }) => {
405                assert_eq!(options, []);
406                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
407                    assert_eq!(first.value, "--");
408                });
409            }
410        );
411
412        assert_matches!(
413            parse(Field::dummies(["-", "-a"])),
414            Ok(Command::Modify {
415                options,
416                positional_params
417            }) => {
418                assert_eq!(options, []);
419                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
420                    assert_eq!(first.value, "-a");
421                });
422            }
423        );
424    }
425
426    #[test]
427    fn short_options() {
428        assert_matches!(
429            parse(Field::dummies(["-a"])),
430            Ok(Command::Modify {
431                options,
432                positional_params
433            }) => {
434                assert_eq!(options, [(AllExport, On)]);
435                assert_eq!(positional_params, None);
436            }
437        );
438
439        assert_matches!(
440            parse(Field::dummies(["-uv"])),
441            Ok(Command::Modify {
442                options,
443                positional_params
444            }) => {
445                assert_eq!(options, [(Unset, Off), (Verbose, On)]);
446                assert_eq!(positional_params, None);
447            }
448        );
449
450        assert_matches!(
451            parse(Field::dummies(["-u", "-v"])),
452            Ok(Command::Modify {
453                options,
454                positional_params
455            }) => {
456                assert_eq!(options, [(Unset, Off), (Verbose, On)]);
457                assert_eq!(positional_params, None);
458            }
459        );
460    }
461
462    #[test]
463    fn negated_short_options() {
464        assert_matches!(
465            parse(Field::dummies(["+a"])),
466            Ok(Command::Modify {
467                options,
468                positional_params
469            }) => {
470                assert_eq!(options, [(AllExport, Off)]);
471                assert_eq!(positional_params, None);
472            }
473        );
474
475        assert_matches!(
476            parse(Field::dummies(["+uv"])),
477            Ok(Command::Modify {
478                options,
479                positional_params
480            }) => {
481                assert_eq!(options, [(Unset, On), (Verbose, Off)]);
482                assert_eq!(positional_params, None);
483            }
484        );
485
486        assert_matches!(
487            parse(Field::dummies(["+u", "-v"])),
488            Ok(Command::Modify {
489                options,
490                positional_params
491            }) => {
492                assert_eq!(options, [(Unset, On), (Verbose, On)]);
493                assert_eq!(positional_params, None);
494            }
495        );
496    }
497
498    #[test]
499    fn o_options() {
500        assert_matches!(
501            parse(Field::dummies(["-oallexpo"])),
502            Ok(Command::Modify {
503                options,
504                positional_params
505            }) => {
506                assert_eq!(options, [(AllExport, On)]);
507                assert_eq!(positional_params, None);
508            }
509        );
510
511        assert_matches!(
512            parse(Field::dummies(["-o all-Expo"])),
513            Ok(Command::Modify {
514                options,
515                positional_params
516            }) => {
517                assert_eq!(options, [(AllExport, On)]);
518                assert_eq!(positional_params, None);
519            }
520        );
521
522        assert_matches!(
523            parse(Field::dummies(["-onounset"])),
524            Ok(Command::Modify {
525                options,
526                positional_params
527            }) => {
528                assert_eq!(options, [(Unset, Off)]);
529                assert_eq!(positional_params, None);
530            }
531        );
532
533        assert_matches!(
534            parse(Field::dummies(["-o","NO_unset"])),
535            Ok(Command::Modify {
536                options,
537                positional_params
538            }) => {
539                assert_eq!(options, [(Unset, Off)]);
540                assert_eq!(positional_params, None);
541            }
542        );
543    }
544
545    #[test]
546    fn negated_o_options() {
547        assert_matches!(
548            parse(Field::dummies(["+oallexpo"])),
549            Ok(Command::Modify {
550                options,
551                positional_params
552            }) => {
553                assert_eq!(options, [(AllExport, Off)]);
554                assert_eq!(positional_params, None);
555            }
556        );
557
558        assert_matches!(
559            parse(Field::dummies(["+o all-Expo"])),
560            Ok(Command::Modify {
561                options,
562                positional_params
563            }) => {
564                assert_eq!(options, [(AllExport, Off)]);
565                assert_eq!(positional_params, None);
566            }
567        );
568
569        assert_matches!(
570            parse(Field::dummies(["+onounset"])),
571            Ok(Command::Modify {
572                options,
573                positional_params
574            }) => {
575                assert_eq!(options, [(Unset, On)]);
576                assert_eq!(positional_params, None);
577            }
578        );
579
580        assert_matches!(
581            parse(Field::dummies(["+o","NO+unset"])),
582            Ok(Command::Modify {
583                options,
584                positional_params
585            }) => {
586                assert_eq!(options, [(Unset, On)]);
587                assert_eq!(positional_params, None);
588            }
589        );
590    }
591
592    #[test]
593    fn long_options() {
594        assert_matches!(
595            parse(Field::dummies(["--allexpo"])),
596            Ok(Command::Modify {
597                options,
598                positional_params
599            }) => {
600                assert_eq!(options, [(AllExport, On)]);
601                assert_eq!(positional_params, None);
602            }
603        );
604
605        assert_matches!(
606            parse(Field::dummies(["-- all-Expo"])),
607            Ok(Command::Modify {
608                options,
609                positional_params
610            }) => {
611                assert_eq!(options, [(AllExport, On)]);
612                assert_eq!(positional_params, None);
613            }
614        );
615
616        assert_matches!(
617            parse(Field::dummies(["--nounset"])),
618            Ok(Command::Modify {
619                options,
620                positional_params
621            }) => {
622                assert_eq!(options, [(Unset, Off)]);
623                assert_eq!(positional_params, None);
624            }
625        );
626    }
627
628    #[test]
629    fn negated_long_options() {
630        assert_matches!(
631            parse(Field::dummies(["++allexpo"])),
632            Ok(Command::Modify {
633                options,
634                positional_params
635            }) => {
636                assert_eq!(options, [(AllExport, Off)]);
637                assert_eq!(positional_params, None);
638            }
639        );
640
641        assert_matches!(
642            parse(Field::dummies(["++ all-Expo"])),
643            Ok(Command::Modify {
644                options,
645                positional_params
646            }) => {
647                assert_eq!(options, [(AllExport, Off)]);
648                assert_eq!(positional_params, None);
649            }
650        );
651
652        assert_matches!(
653            parse(Field::dummies(["++nounset"])),
654            Ok(Command::Modify {
655                options,
656                positional_params
657            }) => {
658                assert_eq!(options, [(Unset, On)]);
659                assert_eq!(positional_params, None);
660            }
661        );
662    }
663
664    #[test]
665    fn options_and_separator() {
666        assert_matches!(
667            parse(Field::dummies(["-a", "--"])),
668            Ok(Command::Modify {
669                options,
670                positional_params
671            }) => {
672                assert_eq!(options, [(AllExport, On)]);
673                assert_eq!(positional_params, Some(vec![]));
674            }
675        );
676
677        assert_matches!(
678            parse(Field::dummies(["-uv", "--", "-a"])),
679            Ok(Command::Modify {
680                options,
681                positional_params
682            }) => {
683                assert_eq!(options, [(Unset, Off), (Verbose, On)]);
684                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
685                    assert_eq!(first.value, "-a");
686                });
687            }
688        );
689
690        assert_matches!(
691            parse(Field::dummies(["-n", "-", "--"])),
692            Ok(Command::Modify {
693                options,
694                positional_params
695            }) => {
696                assert_eq!(options, [(Exec, Off)]);
697                assert_matches!(positional_params.unwrap().as_slice(), [first] => {
698                    assert_eq!(first.value, "--");
699                });
700            }
701        );
702    }
703
704    #[test]
705    fn combinations() {
706        assert_matches!(
707            parse(Field::dummies(["+nononotify", "a", "-a"])),
708            Ok(Command::Modify {
709                options,
710                positional_params
711            }) => {
712                assert_eq!(options, [(Exec, On), (Notify, On)]);
713                assert_matches!(positional_params.unwrap().as_slice(), [first, second] => {
714                    assert_eq!(first.value, "a");
715                    assert_eq!(second.value, "-a");
716                });
717            }
718        );
719
720        assert_matches!(
721            parse(Field::dummies(["-uno", "-notify", "++log", "--", "foo", "-v"])),
722            Ok(Command::Modify {
723                options,
724                positional_params
725            }) => {
726                assert_eq!(options, [(Unset, Off), (Exec, Off), (Notify, On), (Log, Off)]);
727                assert_matches!(positional_params.unwrap().as_slice(), [first, second] => {
728                    assert_eq!(first.value, "foo");
729                    assert_eq!(second.value, "-v");
730                });
731            }
732        );
733    }
734
735    #[test]
736    fn parse_errors() {
737        assert_matches!(
738            parse(Field::dummies(["-n-a"])),
739            Err(Error::UnknownShortOption('-', field)) => {
740                assert_eq!(field.value, "-n-a");
741            }
742        );
743
744        assert_matches!(
745            parse(Field::dummies(["--foo"])),
746            Err(Error::UnknownLongOption(field)) => {
747                assert_eq!(field.value, "--foo");
748            }
749        );
750
751        assert_matches!(
752            parse(Field::dummies(["-ofoo"])),
753            Err(Error::UnknownLongOption(field)) => {
754                assert_eq!(field.value, "-ofoo");
755            }
756        );
757
758        assert_matches!(
759            parse(Field::dummies(["-o", "foo"])),
760            Err(Error::UnknownLongOption(field)) => {
761                assert_eq!(field.value, "foo");
762            }
763        );
764
765        assert_matches!(
766            parse(Field::dummies(["--no"])),
767            Err(Error::AmbiguousLongOption(field)) => {
768                assert_eq!(field.value, "--no");
769            }
770        );
771
772        assert_matches!(
773            parse(Field::dummies(["-oe"])),
774            Err(Error::AmbiguousLongOption(field)) => {
775                assert_eq!(field.value, "-oe");
776            }
777        );
778
779        assert_matches!(
780            parse(Field::dummies(["-eo"])),
781            Err(Error::MissingOptionArgument(field)) => {
782                assert_eq!(field.value, "-eo");
783            }
784        );
785    }
786
787    #[test]
788    fn unmodifiable_options() {
789        assert_matches!(
790            parse(Field::dummies(["-c"])),
791            Err(Error::UnmodifiableShortOption('c', field)) => {
792                assert_eq!(field.value, "-c");
793            }
794        );
795
796        assert_matches!(
797            parse(Field::dummies(["-ointeract"])),
798            Err(Error::UnmodifiableLongOption(field)) => {
799                assert_eq!(field.value, "-ointeract");
800            }
801        );
802
803        assert_matches!(
804            parse(Field::dummies(["-o", "interact"])),
805            Err(Error::UnmodifiableLongOption(field)) => {
806                assert_eq!(field.value, "interact");
807            }
808        );
809
810        assert_matches!(
811            parse(Field::dummies(["++stdin"])),
812            Err(Error::UnmodifiableLongOption(field)) => {
813                assert_eq!(field.value, "++stdin");
814            }
815        );
816    }
817}