fm/modes/utils/
shell_parser.rs1use 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
8pub const SAME_WINDOW_TOKEN: &str = "%t";
11
12pub 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