Skip to main content

hyprforge_mime/
cli.rs

1//! The argument grammar `mimetype` and `mimeopen` share.
2//!
3//! Both are drop-ins for the commands of the same name in
4//! `perl-file-mimeinfo`, and both are invoked by `xdg-open`, so the
5//! grammar *is* the compatibility claim: bundled short options
6//! (`-bl de`), `--long=value` as well as `--long value`, `--` ending the
7//! options, and a bare `-` being a filename rather than a flag.
8//!
9//! It lives here because it was written twice. The two parsers were
10//! identical but for the program name in four error strings, with their
11//! own tests each — so a fix to bundling or to `--` had to be made and
12//! re-tested twice, and a divergence would have meant two commands that
13//! parse their shared options differently. That is the one kind of bug
14//! a compatibility command cannot afford.
15
16/// Exit codes, which are the reference implementation's own.
17pub mod exit {
18    /// An option nothing here knows.
19    pub const UNKNOWN_OPTION: i32 = 1;
20    /// An option that takes an argument, without one.
21    pub const MISSING_ARGUMENT: i32 = 2;
22    /// Nothing to look at.
23    pub const NO_FILES: i32 = 4;
24}
25
26/// One option: its long name, its short letter if it has one, and
27/// whether it takes a value.
28pub type Option_ = (&'static str, Option<char>, bool);
29
30/// What a command line turned out to be: the options given, in order,
31/// each with its value; and everything that was not an option.
32#[derive(Debug, Default, PartialEq, Eq)]
33pub struct Parsed {
34    pub options: Vec<(&'static str, Option<String>)>,
35    pub files: Vec<String>,
36}
37
38/// Splits a command line against an option table.
39///
40/// `program` appears in the error messages, which are the reference's
41/// own wording. An error carries the exit code to use with it, because
42/// the two are one decision: an unknown option is 1, a missing argument
43/// is 2.
44pub fn parse(program: &str, table: &[Option_], raw: Vec<String>) -> Result<Parsed, (String, i32)> {
45    let mut parsed = Parsed::default();
46    let mut rest = raw.into_iter().peekable();
47    while let Some(argument) = rest.peek().cloned() {
48        // A bare `-` is a filename, not an option — and `--` ends the
49        // options, so a file genuinely called `-b` can be asked about.
50        if !argument.starts_with('-') || argument == "-" {
51            break;
52        }
53        rest.next();
54        if argument == "--" {
55            break;
56        }
57        if let Some(long) = argument.strip_prefix("--") {
58            let (name, inline) = match long.split_once('=') {
59                Some((name, value)) => (name, Some(value.to_string())),
60                None => (long, None),
61            };
62            let Some((name, _, takes_value)) = table.iter().find(|(option, _, _)| *option == name)
63            else {
64                return Err((
65                    format!("{program}: unrecognized option '--{name}'"),
66                    exit::UNKNOWN_OPTION,
67                ));
68            };
69            let value = match takes_value {
70                false => None,
71                true => match inline.or_else(|| rest.next()) {
72                    Some(value) => Some(value),
73                    None => {
74                        return Err((
75                            format!("{program}: option '--{name}' requires an argument"),
76                            exit::MISSING_ARGUMENT,
77                        ))
78                    }
79                },
80            };
81            parsed.options.push((name, value));
82            continue;
83        }
84        // `-abc`: each letter is its own option, and one that takes a
85        // value takes the next argument.
86        for letter in argument.trim_start_matches('-').chars() {
87            let Some((name, _, takes_value)) =
88                table.iter().find(|(_, short, _)| *short == Some(letter))
89            else {
90                return Err((
91                    format!("{program}: unrecognized option '{letter}'"),
92                    exit::UNKNOWN_OPTION,
93                ));
94            };
95            let value = match takes_value {
96                false => None,
97                true => match rest.next() {
98                    Some(value) => Some(value),
99                    None => {
100                        return Err((
101                            format!("{program}: option '-{letter}' requires an argument"),
102                            exit::MISSING_ARGUMENT,
103                        ))
104                    }
105                },
106            };
107            parsed.options.push((name, value));
108        }
109    }
110    parsed.files.extend(rest);
111    Ok(parsed)
112}
113
114/// A filename as printed. On a terminal the reference quotes it, so a
115/// name with a space or a newline in it cannot be misread; piped, it
116/// prints the name raw, which is what a script downstream needs.
117pub fn shown_name(file: &str) -> String {
118    use std::io::IsTerminal;
119    if std::io::stdout().is_terminal() {
120        format!("{file:?}")
121    } else {
122        file.to_string()
123    }
124}
125
126/// The directories to read the database from: what `--database` named,
127/// or the XDG ones.
128pub fn database_dirs(database: &Option<String>) -> Vec<std::path::PathBuf> {
129    match database {
130        Some(list) => list.split(':').filter(|d| !d.is_empty()).map(std::path::PathBuf::from).collect(),
131        None => crate::data_dirs(),
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    const TABLE: &[Option_] = &[
140        ("brief", Some('b'), false),
141        ("language", Some('l'), true),
142        ("all", Some('a'), false),
143        ("database", None, true),
144    ];
145
146    fn parsed(argv: &[&str]) -> Parsed {
147        parse("mimetype", TABLE, argv.iter().map(|a| a.to_string()).collect()).expect("these parse")
148    }
149
150    #[test]
151    fn short_options_can_be_bundled_and_the_last_one_takes_the_argument() {
152        let args = parsed(&["-bl", "de", "part.3mf"]);
153        assert_eq!(args.options, [("brief", None), ("language", Some("de".to_string()))]);
154        assert_eq!(args.files, ["part.3mf"]);
155    }
156
157    #[test]
158    fn a_long_option_takes_its_value_attached_or_apart() {
159        assert_eq!(parsed(&["--language=fr", "x"]).options, [("language", Some("fr".to_string()))]);
160        assert_eq!(parsed(&["--language", "fr", "x"]).options, [("language", Some("fr".to_string()))]);
161    }
162
163    /// Options stop at the first thing that is not one, so a file
164    /// called `-b` can be asked about.
165    #[test]
166    fn a_double_dash_ends_the_options() {
167        let args = parsed(&["--brief", "--", "-b", "--all"]);
168        assert_eq!(args.options, [("brief", None)]);
169        assert_eq!(args.files, ["-b", "--all"]);
170    }
171
172    #[test]
173    fn a_bare_dash_is_a_filename_not_an_option() {
174        assert_eq!(parsed(&["-"]).files, ["-"]);
175    }
176
177    #[test]
178    fn an_unknown_option_is_named_with_the_exit_code_the_reference_uses() {
179        let (message, code) = parse("mimetype", TABLE, vec!["--nonsense".to_string()]).unwrap_err();
180        assert!(message.contains("mimetype: unrecognized option '--nonsense'"), "{message}");
181        assert_eq!(code, exit::UNKNOWN_OPTION);
182
183        let (message, code) = parse("mimeopen", TABLE, vec!["--language".to_string()]).unwrap_err();
184        assert!(message.contains("mimeopen: option '--language' requires an argument"), "{message}");
185        assert_eq!(code, exit::MISSING_ARGUMENT);
186    }
187
188    /// The program name in the message is the caller's, since the two
189    /// commands share this parser.
190    #[test]
191    fn the_error_names_the_command_that_was_run() {
192        let (message, _) = parse("mimeopen", TABLE, vec!["-z".to_string()]).unwrap_err();
193        assert!(message.starts_with("mimeopen:"), "{message}");
194    }
195
196    #[test]
197    fn the_database_option_replaces_the_xdg_directories() {
198        let dirs = database_dirs(&Some("/a:/b".to_string()));
199        assert_eq!(dirs, [std::path::PathBuf::from("/a"), std::path::PathBuf::from("/b")]);
200        assert_eq!(database_dirs(&None), crate::data_dirs());
201    }
202}