fm/modes/utils/
shell_parser.rs

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