Skip to main content

binocular/preview/request/
command.rs

1use crate::preview::worker::executor::PreviewExecution;
2use crate::preview::{
3    apply_param_substitutions, PreviewContent, PreviewRequest, PREVIEW_COMMAND_POLL_INTERVAL,
4    PREVIEW_COMMAND_TIMEOUT,
5};
6use ratatui::text::Text;
7use std::io::Write;
8use std::process::{Command, Stdio};
9use std::time::Instant;
10
11pub(crate) fn execute_preview_command<F>(
12    request: PreviewRequest,
13    item: &str,
14    command: &str,
15    delimiter: &str,
16    poll_replacement: &mut F,
17) -> PreviewExecution
18where
19    F: FnMut() -> Option<PreviewRequest>,
20{
21    let Some(argv) = split_command(command) else {
22        return PreviewExecution::Completed(
23            request,
24            PreviewContent::PlainText(Text::from("Invalid --preview command")),
25        );
26    };
27
28    let Some(program) = argv.first() else {
29        return PreviewExecution::Completed(
30            request,
31            PreviewContent::PlainText(Text::from("Empty --preview command")),
32        );
33    };
34
35    let parts: Vec<&str> = item.split(delimiter).collect();
36    let has_placeholder = argv.iter().skip(1).any(|a| a.contains('{'));
37
38    let mut cmd = Command::new(program);
39    for arg in argv.iter().skip(1) {
40        cmd.arg(apply_param_substitutions(arg, item, &parts));
41    }
42
43    if !has_placeholder {
44        cmd.stdin(Stdio::piped());
45    }
46
47    cmd.stdout(Stdio::piped());
48    cmd.stderr(Stdio::piped());
49    cmd.env("BINOCULAR_PREVIEW_ITEM", item);
50
51    let mut child = match cmd.spawn() {
52        Ok(child) => child,
53        Err(err) => {
54            return PreviewExecution::Completed(
55                request,
56                PreviewContent::PlainText(Text::from(format!(
57                    "Failed to start preview command: {}",
58                    err
59                ))),
60            );
61        }
62    };
63
64    if !has_placeholder {
65        if let Some(mut stdin) = child.stdin.take() {
66            let _ = stdin.write_all(item.as_bytes());
67        }
68    }
69
70    let started_at = Instant::now();
71    loop {
72        if let Some(next_request) = poll_replacement() {
73            let _ = child.kill();
74            let _ = child.wait();
75            return PreviewExecution::Superseded(next_request);
76        }
77
78        if started_at.elapsed() >= PREVIEW_COMMAND_TIMEOUT {
79            let _ = child.kill();
80            let _ = child.wait();
81            return PreviewExecution::Completed(
82                request,
83                PreviewContent::PlainText(Text::from(format!(
84                    "Preview command timed out after {}s",
85                    PREVIEW_COMMAND_TIMEOUT.as_secs()
86                ))),
87            );
88        }
89
90        match child.try_wait() {
91            Ok(Some(_)) => break,
92            Ok(None) => std::thread::sleep(PREVIEW_COMMAND_POLL_INTERVAL),
93            Err(err) => {
94                return PreviewExecution::Completed(
95                    request,
96                    PreviewContent::PlainText(Text::from(format!(
97                        "Failed to poll preview command: {}",
98                        err
99                    ))),
100                );
101            }
102        }
103    }
104
105    let output = match child.wait_with_output() {
106        Ok(output) => output,
107        Err(err) => {
108            return PreviewExecution::Completed(
109                request,
110                PreviewContent::PlainText(Text::from(format!(
111                    "Failed to read preview command output: {}",
112                    err
113                ))),
114            );
115        }
116    };
117
118    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
119    let stderr = String::from_utf8_lossy(&output.stderr);
120    if !stderr.trim().is_empty() {
121        if !text.is_empty() {
122            text.push_str("\n\n");
123        }
124        text.push_str(&stderr);
125    }
126
127    if text.trim().is_empty() {
128        text = if output.status.success() {
129            "Preview command produced no output".to_string()
130        } else {
131            format!("Preview command exited with status {}", output.status)
132        };
133    }
134
135    PreviewExecution::Completed(request, PreviewContent::PlainText(Text::from(text)))
136}
137
138/// Split a string into words using basic shell-like quoting rules.
139///
140/// Supports:
141/// - Single quotes: `'...'`
142/// - Double quotes: `"..."`
143/// - Backslash escapes outside quotes and inside double quotes
144///
145/// Returns `None` for unmatched quotes or a trailing unescaped backslash.
146fn split_command(input: &str) -> Option<Vec<String>> {
147    let mut words = Vec::new();
148    let mut current = String::new();
149    let mut chars = input.chars().peekable();
150    let mut in_single = false;
151    let mut in_double = false;
152    let mut in_word = false;
153
154    while let Some(ch) = chars.next() {
155        if in_single {
156            if ch == '\'' {
157                in_single = false;
158            } else {
159                current.push(ch);
160            }
161            in_word = true;
162        } else if in_double {
163            if ch == '\\' {
164                match chars.next() {
165                    Some(c @ ('"' | '\\' | '$' | '`')) => current.push(c),
166                    Some('\n') => {}
167                    Some(next) => {
168                        current.push('\\');
169                        current.push(next);
170                    }
171                    None => return None,
172                }
173            } else if ch == '"' {
174                in_double = false;
175            } else {
176                current.push(ch);
177            }
178            in_word = true;
179        } else {
180            match ch {
181                '\\' => {
182                    match chars.next() {
183                        Some(next) => current.push(next),
184                        None => return None,
185                    }
186                    in_word = true;
187                }
188                '\'' => {
189                    in_single = true;
190                    in_word = true;
191                }
192                '"' => {
193                    in_double = true;
194                    in_word = true;
195                }
196                c if c.is_whitespace() => {
197                    if in_word {
198                        words.push(std::mem::take(&mut current));
199                        in_word = false;
200                    }
201                }
202                _ => {
203                    current.push(ch);
204                    in_word = true;
205                }
206            }
207        }
208    }
209
210    if in_single || in_double {
211        return None;
212    }
213
214    if in_word {
215        words.push(current);
216    }
217
218    Some(words)
219}