Skip to main content

yash_builtin/command/
syntax.rs

1// This file is part of yash, an extended POSIX shell.
2// Copyright (C) 2024 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 command built-in
18
19use super::Command;
20use super::Identify;
21use super::Invoke;
22use super::Search;
23use crate::common::syntax::Mode;
24use crate::common::syntax::OptionOccurrence;
25use crate::common::syntax::OptionSpec;
26use crate::common::syntax::ParseError;
27use crate::common::syntax::parse_arguments;
28use thiserror::Error;
29use yash_env::Env;
30use yash_env::semantics::Field;
31use yash_env::source::pretty::Report;
32
33/// Error in parsing command line arguments
34#[derive(Clone, Debug, Eq, Error, PartialEq)]
35#[non_exhaustive]
36pub enum Error {
37    /// An error occurred in the common parser.
38    #[error(transparent)]
39    CommonError(#[from] ParseError<'static>),
40    // TODO MissingCommandName
41    // TODO TooManyCommandNames
42    // TODO UninvokableCategory
43}
44
45impl Error {
46    /// Converts this error to a [`Report`].
47    #[must_use]
48    pub fn to_report(&self) -> Report<'_> {
49        match self {
50            Self::CommonError(e) => e.to_report(),
51        }
52    }
53}
54
55impl<'a> From<&'a Error> for Report<'a> {
56    #[inline]
57    fn from(error: &'a Error) -> Self {
58        error.to_report()
59    }
60}
61
62const OPTION_SPECS: &[OptionSpec] = &[
63    OptionSpec::new().short('p').long("path"),
64    OptionSpec::new().short('v').long("identify"),
65    OptionSpec::new().short('V').long("verbose-identify"),
66];
67
68/// Interprets the parsed command line arguments
69///
70/// This function converts the result of [`parse_arguments`] into a `Command`.
71pub fn interpret(
72    options: Vec<OptionOccurrence<'_>>,
73    operands: Vec<Field>,
74) -> Result<Command, Error> {
75    // Interpret options
76    let mut standard_path = false;
77    let mut verbose_identify = None;
78    for option in options {
79        match option.spec.get_short() {
80            Some('p') => standard_path = true,
81            Some('v') => verbose_identify = Some(false),
82            Some('V') => verbose_identify = Some(true),
83            _ => unreachable!("unhandled option: {:?}", option),
84        }
85    }
86
87    // Produce the result
88    if let Some(verbose) = verbose_identify {
89        let mut search = Search::default_for_identify();
90        search.standard_path = standard_path;
91        let identify = Identify {
92            names: operands,
93            search,
94            verbose,
95        };
96        Ok(identify.into())
97    } else {
98        let mut search = Search::default_for_invoke();
99        search.standard_path = standard_path;
100        let fields = operands;
101        let invoke = Invoke { fields, search };
102        Ok(invoke.into())
103    }
104}
105
106/// Parses command line arguments of the `command` built-in
107pub fn parse<S>(env: &Env<S>, args: Vec<Field>) -> Result<Command, Error> {
108    let (options, operands) = parse_arguments(OPTION_SPECS, Mode::with_env(env), args)?;
109    interpret(options, operands)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::command::Category;
116    use assert_matches::assert_matches;
117    use enumset::EnumSet;
118
119    #[test]
120    fn invoke_without_options() {
121        let env = Env::new_virtual();
122        let result = parse(&env, Field::dummies(["foo", "bar", "baz"]));
123
124        assert_matches!(result, Ok(Command::Invoke(invoke)) => {
125            assert_eq!(invoke.fields, Field::dummies(["foo", "bar", "baz"]));
126            assert_eq!(
127                invoke.search,
128                Search {
129                    standard_path: false,
130                    categories: Category::Builtin | Category::ExternalUtility
131                }
132            );
133        });
134    }
135
136    #[test]
137    fn invoke_with_p_option() {
138        let env = Env::new_virtual();
139        let result = parse(&env, Field::dummies(["-p", "foo"]));
140
141        assert_matches!(result, Ok(Command::Invoke(invoke)) => {
142            assert_eq!(invoke.fields, Field::dummies(["foo"]));
143            assert_eq!(
144                invoke.search,
145                Search {
146                    standard_path: true,
147                    categories: Category::Builtin | Category::ExternalUtility
148                }
149            );
150        });
151    }
152
153    #[test]
154    fn identify_without_options() {
155        let env = Env::new_virtual();
156        let result = parse(&env, Field::dummies(["-v", "foo"]));
157
158        assert_matches!(result, Ok(Command::Identify(identify)) => {
159            assert_eq!(identify.names, Field::dummies(["foo"]));
160            assert_eq!(
161                identify.search,
162                Search {
163                    standard_path: false,
164                    categories: EnumSet::all()
165                }
166            );
167            assert!(!identify.verbose);
168        });
169    }
170
171    #[test]
172    fn identify_with_p_option() {
173        let env = Env::new_virtual();
174        let result = parse(&env, Field::dummies(["-v", "-p", "foo"]));
175
176        assert_matches!(result, Ok(Command::Identify(identify)) => {
177            assert_eq!(identify.names, Field::dummies(["foo"]));
178            assert_eq!(
179                identify.search,
180                Search {
181                    standard_path: true,
182                    categories: EnumSet::all()
183                }
184            );
185            assert!(!identify.verbose);
186        });
187    }
188
189    #[test]
190    fn verbosely_identify_without_options() {
191        let env = Env::new_virtual();
192        let result = parse(&env, Field::dummies(["-V", "bar"]));
193
194        assert_matches!(result, Ok(Command::Identify(identify)) => {
195            assert_eq!(identify.names, Field::dummies(["bar"]));
196            assert_eq!(
197                identify.search,
198                Search {
199                    standard_path: false,
200                    categories: EnumSet::all()
201                }
202            );
203            assert!(identify.verbose);
204        });
205    }
206
207    // This ordering is not specified by POSIX, but it is consistent with the
208    // older versions of yash.
209    #[test]
210    #[allow(non_snake_case)]
211    fn last_specified_option_wins_between_v_and_V() {
212        let env = Env::new_virtual();
213
214        let result = parse(&env, Field::dummies(["-V", "-v", "baz"]));
215        assert_matches!(result, Ok(Command::Identify(identify)) => {
216            assert!(!identify.verbose);
217        });
218
219        let result = parse(&env, Field::dummies(["-v", "-V", "baz"]));
220        assert_matches!(result, Ok(Command::Identify(identify)) => {
221            assert!(identify.verbose);
222        });
223    }
224}