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