Skip to main content

fm/modes/utils/
shell_parser.rs

1use anyhow::{bail, Context, Result};
2
3use crate::app::Status;
4use crate::common::{get_clipboard, path_to_string};
5use crate::modes::{Quote, Selectable};
6use crate::{log_info, log_line};
7
8/// Token used while parsing a command to execute it using the current window.
9/// Useful for applications which output to stdout like TUI or CLI applications.
10pub const SAME_WINDOW_TOKEN: &str = "%t";
11
12/// Analyse, parse and builds arguments from a shell command.
13/// Normal commands are executed with `sh -c "command"` which allow redirection, pipes etc.
14/// Sudo commands are executed with `sudo` then `sh -c "rest of the command"`.
15/// The password will be asked, injected into stdin and dropped somewhere else.
16/// The command isn't executed here, we just build a list of arguments to be passed to an executer.
17///
18/// Some expansion are allowed to interact with the content of fm.
19/// Expanded tokens from a configured command.
20/// %s is converted into a `Selected`
21/// %f is converted into a `Flagged`
22/// %e is converted into a `Extension`
23/// %n is converted into a `Filename`
24/// %t is converted into a `$TERM` + custom flag.
25/// %c is converted into a `Clipboard content`.
26/// %x is converted into a `Selected or Flagged`.
27/// Everything else is left intact and wrapped into an `Arg(string)`.
28///
29/// # Errors
30///
31/// It can fail if the command can't be analysed or the expansion aren't valid (see above).
32pub fn shell_command_parser(command: &str, status: &Status) -> Result<Vec<String>> {
33    let Ok(tokens) = Lexer::new(command).lexer() else {
34        return shell_command_parser_error("Syntax error in the command", command);
35    };
36    let Ok(args) = Parser::new(tokens).parse(status) else {
37        return shell_command_parser_error("Couldn't parse the command", command);
38    };
39    build_args(args)
40}
41
42fn shell_command_parser_error(message: &str, command: &str) -> Result<Vec<String>> {
43    log_info!("{message} {command}");
44    log_line!("{message} {command}");
45    bail!("{message} {command}");
46}
47
48#[derive(Debug)]
49enum Token {
50    Identifier(String),
51    StringLiteral((char, String)),
52    FmExpansion(FmExpansion),
53}
54
55#[derive(Debug)]
56enum FmExpansion {
57    Selected,
58    SelectedFilename,
59    SelectedPath,
60    Extension,
61    Flagged,
62    Term,
63    Clipboard,
64    SelectedOrFlagged,
65    Invalid,
66}
67
68impl FmExpansion {
69    fn from(c: char) -> Self {
70        match c {
71            's' => Self::Selected,
72            'n' => Self::SelectedFilename,
73            'd' => Self::SelectedPath,
74            'e' => Self::Extension,
75            'f' => Self::Flagged,
76            't' => Self::Term,
77            'c' => Self::Clipboard,
78            'x' => Self::SelectedOrFlagged,
79            _ => Self::Invalid,
80        }
81    }
82
83    fn parse(&self, status: &Status) -> Result<Vec<String>> {
84        match self {
85            Self::Invalid => bail!("Invalid Fm Expansion"),
86            Self::Term => Self::term(),
87            Self::Selected => Self::selected(status),
88            Self::Flagged => Self::flagged(status),
89            Self::SelectedPath => Self::path(status),
90            Self::SelectedFilename => Self::filename(status),
91            Self::Clipboard => Self::clipboard(),
92            Self::Extension => Self::extension(status),
93            Self::SelectedOrFlagged => Self::selected_or_flagged(status),
94        }
95    }
96
97    fn selected(status: &Status) -> Result<Vec<String>> {
98        Ok(vec![status.current_tab().current_file_string()?.quote()?])
99    }
100
101    fn path(status: &Status) -> Result<Vec<String>> {
102        Ok(vec![status.current_tab().directory_str().quote()?])
103    }
104
105    fn filename(status: &Status) -> Result<Vec<String>> {
106        Ok(vec![status
107            .current_tab()
108            .selected_path()
109            .context("No selected file")?
110            .file_name()
111            .context("No filename")?
112            .quote()?])
113    }
114
115    fn extension(status: &Status) -> Result<Vec<String>> {
116        Ok(vec![status
117            .current_tab()
118            .selected_path()
119            .context("No selected file")?
120            .extension()
121            .context("No extension")?
122            .quote()?])
123    }
124
125    fn flagged(status: &Status) -> Result<Vec<String>> {
126        Ok(status
127            .menu
128            .flagged
129            .content
130            .iter()
131            .map(path_to_string)
132            .filter_map(|s| s.quote().ok())
133            .collect())
134    }
135
136    fn term() -> Result<Vec<String>> {
137        Ok(vec![SAME_WINDOW_TOKEN.to_owned()])
138    }
139
140    fn clipboard() -> Result<Vec<String>> {
141        let Some(clipboard) = get_clipboard() else {
142            bail!("Couldn't read the clipboard");
143        };
144        Ok(clipboard.split_whitespace().map(|s| s.to_owned()).collect())
145    }
146
147    fn selected_or_flagged(status: &Status) -> Result<Vec<String>> {
148        if status.menu.flagged.is_empty() {
149            Self::selected(status)
150        } else {
151            Self::flagged(status)
152        }
153    }
154}
155
156enum State {
157    Start,
158    Arg,
159    StringLiteral(char),
160    FmExpansion,
161}
162
163struct Lexer {
164    command: String,
165}
166
167impl Lexer {
168    fn new(command: &str) -> Self {
169        Self {
170            command: command.trim().to_owned(),
171        }
172    }
173
174    fn lexer(&self) -> Result<Vec<Token>> {
175        let mut tokens = vec![];
176        let mut state = State::Start;
177        let mut current = String::new();
178
179        for c in self.command.chars() {
180            match &state {
181                State::Start => {
182                    if c == '"' || c == '\'' {
183                        state = State::StringLiteral(c);
184                    } else if c == '%' {
185                        state = State::FmExpansion;
186                    } else {
187                        state = State::Arg;
188                        current.push(c);
189                    }
190                }
191                State::Arg => {
192                    if c == '%' {
193                        tokens.push(Token::Identifier(current.clone()));
194                        current.clear();
195                        state = State::FmExpansion;
196                    } else if c == '"' || c == '\'' {
197                        tokens.push(Token::Identifier(current.clone()));
198                        current.clear();
199                        state = State::StringLiteral(c);
200                    } else {
201                        current.push(c);
202                    }
203                }
204                State::StringLiteral(quote_type) => {
205                    if c == *quote_type {
206                        tokens.push(Token::StringLiteral((c, current.clone())));
207                        current.clear();
208                        state = State::Start;
209                    } else {
210                        current.push(c);
211                    }
212                }
213                State::FmExpansion => {
214                    if c.is_alphanumeric() {
215                        let expansion = FmExpansion::from(c);
216                        if let FmExpansion::Invalid = expansion {
217                            bail!("Invalid FmExpansion %{c}")
218                        }
219                        if let FmExpansion::Term = expansion {
220                            if !tokens.is_empty() {
221                                bail!("Term expansion can only be the first argument")
222                            }
223                        }
224                        tokens.push(Token::FmExpansion(expansion));
225                        current.clear();
226                        state = State::Start;
227                    } else {
228                        bail!("Invalid FmExpansion %{c}.")
229                    }
230                }
231            }
232        }
233
234        match &state {
235            State::Arg => tokens.push(Token::Identifier(current)),
236            State::StringLiteral(quote) => tokens.push(Token::StringLiteral((*quote,current))),
237            State::FmExpansion => bail!("Invalid syntax for {command}. Matching an FmExpansion with {current} which is impossible.", command=self.command),
238            State::Start => (),
239        }
240
241        Ok(tokens)
242    }
243}
244
245struct Parser {
246    tokens: Vec<Token>,
247}
248
249impl Parser {
250    fn new(tokens: Vec<Token>) -> Self {
251        Self { tokens }
252    }
253
254    fn parse(&self, status: &Status) -> Result<Vec<String>> {
255        if self.tokens.is_empty() {
256            bail!("Empty tokens")
257        }
258        let mut args: Vec<String> = vec![];
259        for token in self.tokens.iter() {
260            match token {
261                Token::Identifier(identifier) => args.push(identifier.to_owned()),
262                Token::FmExpansion(fm_expansion) => {
263                    let Ok(mut expansion) = fm_expansion.parse(status) else {
264                        log_line!("Invalid expansion {fm_expansion:?}");
265                        log_info!("Invalid expansion {fm_expansion:?}");
266                        bail!("Invalid expansion {fm_expansion:?}")
267                    };
268                    args.append(&mut expansion)
269                }
270                Token::StringLiteral((quote, string)) => {
271                    args.push(format!("{quote}{string}{quote}"))
272                }
273            };
274        }
275        Ok(args)
276    }
277}
278
279fn build_args(args: Vec<String>) -> Result<Vec<String>> {
280    log_info!("build_args {args:?}");
281    if args.is_empty() {
282        bail!("Empty command");
283    }
284    if args[0].starts_with("sudo") {
285        Ok(build_sudo_args(args))
286    } else if args[0].starts_with(SAME_WINDOW_TOKEN) {
287        Ok(args)
288    } else {
289        Ok(build_normal_args(args))
290    }
291}
292
293fn build_sudo_args(args: Vec<String>) -> Vec<String> {
294    let rebuild = args.join("");
295    rebuild.split_whitespace().map(|s| s.to_owned()).collect()
296}
297
298fn build_normal_args(args: Vec<String>) -> Vec<String> {
299    vec!["sh".to_owned(), "-c".to_owned(), args.join(" ")]
300}
301// fn test_shell_parser(status: &Status) {
302//     let commands = vec![
303//         r#"echo "Hello World" | grep "World""#, // Commande simple avec pipe et chaîne avec espaces
304//         r#"ls -l /home/user && echo "Done""#, // Commande avec opérateur logique `&&` et chaîne avec espaces
305//         r#"cat file.txt > output.txt"#,       // Redirection de sortie vers un fichier
306//         r#"grep 'pattern' < input.txt | sort >> output.txt"#, // Redirections d'entrée et de sortie avec pipe
307//         r#"echo "Unfinished quote"#,                          // Cas avec guillemet non fermé
308//         r#"echo "Special chars: $HOME, * and ?""#, // Commande avec variables et jokers dans une chaîne
309//         r#"rm -rf /some/directory || echo "Failed to delete""#, // Commande avec opérateur logique `||`
310//         r#"find . -name "*.txt" | xargs grep "Hello""#, // Recherche de fichiers avec pipe et argument contenant `*`
311//         r#"echo "Spaces   between   words""#,           // Chaîne avec plusieurs espaces
312//         r#"echo Hello\ World"#, // Utilisation de `\` pour échapper un espace
313//         r#"ls %s"#,
314//         r#"bat %s --color=never | rg "main" --line-numbers"#,
315//     ];
316//
317//     for command in &commands {
318//         let tokens = Lexer::new(command).lexer();
319//         // crate::log_info!("{command}\n--lexer-->\n{:?}\n", tokens);
320//         if let Ok(tokens) = tokens {
321//             let p = Parser::new(tokens).parse(status);
322//             let c = build_command(p);
323//             crate::log_info!("command: {c:?}\n");
324//         }
325//     }
326// }