|
1 |
+ |
//! Handing a region of the buffer to another program.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! The one thing only a terminal can do is own the buffer: once a line scrolls
|
|
4 |
+ |
//! off, nothing else has it. Searching that text, paging it, opening a URL in
|
|
5 |
+ |
//! it — any program can do those given the bytes. So shop keeps the addressing
|
|
6 |
+ |
//! and delegates the rest, which is why it has no scrollback search.
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! # Why the runner is configured, and not detected
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! A terminal is the bottom of the stack. Kakoune composes for free because a
|
|
11 |
+ |
//! shell exists underneath it; shop IS the thing that runs programs, so it
|
|
12 |
+ |
//! cannot shell out without answering "and where does the output appear?".
|
|
13 |
+ |
//!
|
|
14 |
+ |
//! That answer is the user's. The tmux user wants `display-popup`, the zellij
|
|
15 |
+ |
//! user wants `action new-pane`, the sway user wants something else, and only
|
|
16 |
+ |
//! they know. One config key, and shop writes zero code per multiplexer:
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! ```toml
|
|
19 |
+ |
//! [emit]
|
|
20 |
+ |
//! runner = ["hx", "{file}"]
|
|
21 |
+ |
//! # tmux: ["tmux", "display-popup", "-E", "hx {file}"]
|
|
22 |
+ |
//! # zellij: ["zellij", "action", "new-pane", "--", "hx", "{file}"]
|
|
23 |
+ |
//! ```
|
|
24 |
+ |
//!
|
|
25 |
+ |
//! Shop does NOT look at `$TMUX` and quietly change behaviour. A window that
|
|
26 |
+ |
//! opens somewhere unexpected should be explained by one key in a config file,
|
|
27 |
+ |
//! not by a heuristic the user has to read the source to discover.
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! # A file, not a pipe
|
|
30 |
+ |
//!
|
|
31 |
+ |
//! stdin does not survive `tmux display-popup` or a detached window spawn, so
|
|
32 |
+ |
//! piping would work for exactly one runner and break every other. Shop writes
|
|
33 |
+ |
//! the region to a file and substitutes `{file}`, which works for runners
|
|
34 |
+ |
//! nobody has thought of yet and survives shop exiting.
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! The file is not deleted. Under `new-window` shop does not own the child, and
|
|
37 |
+ |
//! an editor can outlive the terminal that opened it.
|
|
38 |
+ |
//!
|
|
39 |
+ |
//! # One direction
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! Kakoune's `|` replaces the selection with the program's output. Shop cannot
|
|
42 |
+ |
//! and must not: the scrollback is a transcript of something that already
|
|
43 |
+ |
//! happened, and the shell owns the PTY. Shop's primitive is `>`, not `|`.
|
|
44 |
+ |
//! That is what makes fire-and-forget legitimate.
|
|
45 |
+ |
|
|
46 |
+ |
use std::io::Write;
|
|
47 |
+ |
use std::path::{Path, PathBuf};
|
|
48 |
+ |
use std::process::Command;
|
|
49 |
+ |
|
|
50 |
+ |
use tracing::warn;
|
|
51 |
+ |
|
|
52 |
+ |
/// The placeholder a runner uses to say where the file goes.
|
|
53 |
+ |
const FILE_PLACEHOLDER: &str = "{file}";
|
|
54 |
+ |
|
|
55 |
+ |
/// The config spelling of [`Runner::NewWindow`].
|
|
56 |
+ |
const NEW_WINDOW: &str = "new-window";
|
|
57 |
+ |
|
|
58 |
+ |
/// Where a captured region goes.
|
|
59 |
+ |
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
60 |
+ |
pub(crate) enum Runner {
|
|
61 |
+ |
/// Spawn a new shop window running the user's editor on the file.
|
|
62 |
+ |
///
|
|
63 |
+ |
/// The default because it needs no multiplexer and works on a bare
|
|
64 |
+ |
/// desktop: `shop -e` already does the work.
|
|
65 |
+ |
#[default]
|
|
66 |
+ |
NewWindow,
|
|
67 |
+ |
/// Run this argv, with `{file}` substituted into each element.
|
|
68 |
+ |
///
|
|
69 |
+ |
/// An argv array rather than a shell string, so quoting never becomes
|
|
70 |
+ |
/// shop's problem: the user's editor can live in a path with a space in it
|
|
71 |
+ |
/// and nobody has to think about it.
|
|
72 |
+ |
Argv(Vec<String>),
|
|
73 |
+ |
}
|
|
74 |
+ |
|
|
75 |
+ |
impl Runner {
|
|
76 |
+ |
/// Read the `[emit]` table's `runner` key.
|
|
77 |
+ |
///
|
|
78 |
+ |
/// A malformed value warns and falls back to the default, on the same
|
|
79 |
+ |
/// grounds as a bad theme: this is the program somebody opens to fix the
|
|
80 |
+ |
/// mistake, so it has to come up.
|
|
81 |
+ |
pub(crate) fn from_table(table: &toml::Table) -> Self {
|
|
82 |
+ |
let Some(value) = table.get("runner") else {
|
|
83 |
+ |
return Self::default();
|
|
84 |
+ |
};
|
|
85 |
+ |
if let Some(name) = value.as_str() {
|
|
86 |
+ |
if name.eq_ignore_ascii_case(NEW_WINDOW) {
|
|
87 |
+ |
return Self::NewWindow;
|
|
88 |
+ |
}
|
|
89 |
+ |
warn!(
|
|
90 |
+ |
runner = %name,
|
|
91 |
+ |
"a string runner must be \"new-window\"; give an argv array instead, \
|
|
92 |
+ |
e.g. runner = [\"hx\", \"{{file}}\"]",
|
|
93 |
+ |
);
|
|
94 |
+ |
return Self::default();
|
|
95 |
+ |
}
|
|
96 |
+ |
let Some(array) = value.as_array() else {
|
|
97 |
+ |
warn!("[emit] runner must be \"new-window\" or an argv array");
|
|
98 |
+ |
return Self::default();
|
|
99 |
+ |
};
|
|
100 |
+ |
let argv: Option<Vec<String>> = array
|
|
101 |
+ |
.iter()
|
|
102 |
+ |
.map(|v| v.as_str().map(str::to_owned))
|
|
103 |
+ |
.collect();
|
|
104 |
+ |
let Some(argv) = argv.filter(|a| !a.is_empty()) else {
|
|
105 |
+ |
warn!("[emit] runner must be a non-empty array of strings");
|
|
106 |
+ |
return Self::default();
|
|
107 |
+ |
};
|
|
108 |
+ |
if !argv.iter().any(|a| a.contains(FILE_PLACEHOLDER)) {
|
|
109 |
+ |
// Not fatal: a runner could read a fixed path. Almost always a
|
|
110 |
+ |
// typo, though, and a runner that opens on nothing is a confusing
|
|
111 |
+ |
// way to find that out.
|
|
112 |
+ |
warn!(
|
|
113 |
+ |
"[emit] runner names no {FILE_PLACEHOLDER}; the captured text will not be \
|
|
114 |
+ |
passed to it",
|
|
115 |
+ |
);
|
|
116 |
+ |
}
|
|
117 |
+ |
Self::Argv(argv)
|
|
118 |
+ |
}
|
|
119 |
+ |
|
|
120 |
+ |
/// The argv to spawn for `file`.
|
|
121 |
+ |
///
|
|
122 |
+ |
/// `None` when shop cannot locate its own binary, which is the only way
|
|
123 |
+ |
/// `new-window` can fail to produce one.
|
|
124 |
+ |
fn argv(&self, file: &Path) -> Option<Vec<String>> {
|
|
125 |
+ |
let file = file.to_string_lossy();
|
|
126 |
+ |
match self {
|
|
127 |
+ |
Self::NewWindow => {
|
|
128 |
+ |
let exe = std::env::current_exe()
|
|
129 |
+ |
.inspect_err(|error| {
|
|
130 |
+ |
warn!(%error, "cannot find shop's own binary to open a new window");
|
|
131 |
+ |
})
|
|
132 |
+ |
.ok()?;
|
|
133 |
+ |
// current_exe, not "shop" on PATH: a shop built from a checkout
|
|
134 |
+ |
// and run in place should open itself, not whatever else is
|
|
135 |
+ |
// installed under that name.
|
|
136 |
+ |
Some(vec![
|
|
137 |
+ |
exe.to_string_lossy().into_owned(),
|
|
138 |
+ |
"-e".into(),
|
|
139 |
+ |
editor(),
|
|
140 |
+ |
file.into_owned(),
|
|
141 |
+ |
])
|
|
142 |
+ |
}
|
|
143 |
+ |
// Substring substitution, not whole-element: the tmux form puts the
|
|
144 |
+ |
// placeholder inside a larger argument (`-E`, `"hx {file}"`).
|
|
145 |
+ |
Self::Argv(argv) => Some(
|
|
146 |
+ |
argv.iter()
|
|
147 |
+ |
.map(|a| a.replace(FILE_PLACEHOLDER, &file))
|
|
148 |
+ |
.collect(),
|
|
149 |
+ |
),
|
|
150 |
+ |
}
|
|
151 |
+ |
}
|
|
152 |
+ |
}
|
|
153 |
+ |
|
|
154 |
+ |
/// The program `new-window` opens the capture in.
|
|
155 |
+ |
///
|
|
156 |
+ |
/// `$VISUAL` then `$EDITOR`, the long-standing pair, then `less`. A transcript
|
|
157 |
+ |
/// is something to read and search, so a pager is a defensible floor when the
|
|
158 |
+ |
/// user has expressed no preference — and unlike an editor, `less` is on
|
|
159 |
+ |
/// essentially every machine that has a terminal on it.
|
|
160 |
+ |
fn editor() -> String {
|
|
161 |
+ |
for key in ["VISUAL", "EDITOR"] {
|
|
162 |
+ |
if let Some(value) = std::env::var_os(key)
|
|
163 |
+ |
.map(|v| v.to_string_lossy().into_owned())
|
|
164 |
+ |
.filter(|v| !v.trim().is_empty())
|
|
165 |
+ |
{
|
|
166 |
+ |
return value;
|
|
167 |
+ |
}
|
|
168 |
+ |
}
|
|
169 |
+ |
"less".into()
|
|
170 |
+ |
}
|
|
171 |
+ |
|
|
172 |
+ |
/// Write `text` somewhere the runner can read it.
|
|
173 |
+ |
///
|
|
174 |
+ |
/// `$XDG_RUNTIME_DIR` when there is one: it is per-user, already mode 0700, and
|
|
175 |
+ |
/// cleared at logout, so a transcript of somebody's terminal does not sit in a
|
|
176 |
+ |
/// world-readable `/tmp` until the next reboot. Falls back to the system temp
|
|
177 |
+ |
/// dir, which is better than not emitting at all.
|
|
178 |
+ |
fn capture_dir() -> PathBuf {
|
|
179 |
+ |
std::env::var_os("XDG_RUNTIME_DIR")
|
|
180 |
+ |
.map(PathBuf::from)
|
|
181 |
+ |
.filter(|d| d.is_absolute())
|
|
182 |
+ |
.unwrap_or_else(std::env::temp_dir)
|
|
183 |
+ |
}
|
|
184 |
+ |
|
|
185 |
+ |
/// Write `text` into `dir` under a name no other capture will take.
|
|
186 |
+ |
///
|
|
187 |
+ |
/// The directory is a parameter rather than read here so this is testable
|
|
188 |
+ |
/// without mutating the process environment, which tests running in parallel
|
|
189 |
+ |
/// share.
|
|
190 |
+ |
fn write_capture(dir: &Path, text: &str, pid: u32, seq: u64) -> std::io::Result<PathBuf> {
|
|
191 |
+ |
// pid plus a counter: two windows emitting at once must not collide, and
|
|
192 |
+ |
// neither must two emits from the same window.
|
|
193 |
+ |
let path = dir.join(format!("shop-{pid}-{seq}.txt"));
|
|
194 |
+ |
let mut file = std::fs::File::create(&path)?;
|
|
195 |
+ |
file.write_all(text.as_bytes())?;
|
|
196 |
+ |
Ok(path)
|
|
197 |
+ |
}
|
|
198 |
+ |
|
|
199 |
+ |
/// Write the captured text and hand it to the runner.
|
|
200 |
+ |
///
|
|
201 |
+ |
/// Fire and forget: shop does not wait, does not read the child's output, and
|
|
202 |
+ |
/// does not care when it exits. The emit is one-directional, so there is
|
|
203 |
+ |
/// nothing to come back.
|
|
204 |
+ |
///
|
|
205 |
+ |
/// `seq` distinguishes captures from one window; the caller advances it.
|
|
206 |
+ |
pub(crate) fn emit(runner: &Runner, text: &str, seq: u64) {
|
|
207 |
+ |
if text.is_empty() {
|
|
208 |
+ |
// Nothing selected, or an empty screen. Spawning an editor on an empty
|
|
209 |
+ |
// file would be a confusing way to say so.
|
|
210 |
+ |
return;
|
|
211 |
+ |
}
|
|
212 |
+ |
let path = match write_capture(&capture_dir(), text, std::process::id(), seq) {
|
|
213 |
+ |
Ok(path) => path,
|
|
214 |
+ |
Err(error) => {
|
|
215 |
+ |
warn!(%error, "cannot write the captured region");
|
|
216 |
+ |
return;
|
|
217 |
+ |
}
|
|
218 |
+ |
};
|
|
219 |
+ |
let Some(argv) = runner.argv(&path) else {
|
|
220 |
+ |
return;
|
|
221 |
+ |
};
|
|
222 |
+ |
let (program, args) = argv.split_first().expect("argv is never empty");
|
|
223 |
+ |
match Command::new(program).args(args).spawn() {
|
|
224 |
+ |
Ok(child) => {
|
|
225 |
+ |
// Reap on a detached thread. Dropping a Child does not wait, so a
|
|
226 |
+ |
// long-lived terminal would otherwise collect a zombie per emit.
|
|
227 |
+ |
// A thread costs nothing at this rate and keeps the loop free of
|
|
228 |
+ |
// SIGCHLD handling.
|
|
229 |
+ |
std::thread::spawn(move || {
|
|
230 |
+ |
let mut child = child;
|
|
231 |
+ |
let _ = child.wait();
|
|
232 |
+ |
});
|
|
233 |
+ |
tracing::info!(path = %path.display(), program, "emitted a region");
|
|
234 |
+ |
}
|
|
235 |
+ |
Err(error) => {
|
|
236 |
+ |
warn!(%error, program, "cannot run the emit runner");
|
|
237 |
+ |
}
|
|
238 |
+ |
}
|
|
239 |
+ |
}
|
|
240 |
+ |
|
|
241 |
+ |
#[cfg(test)]
|
|
242 |
+ |
mod tests {
|
|
243 |
+ |
use super::*;
|
|
244 |
+ |
|
|
245 |
+ |
fn table(src: &str) -> toml::Table {
|
|
246 |
+ |
src.parse().expect("test config parses")
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
#[test]
|
|
250 |
+ |
fn an_absent_runner_is_a_new_window() {
|
|
251 |
+ |
assert_eq!(Runner::from_table(&table("")), Runner::NewWindow);
|
|
252 |
+ |
}
|
|
253 |
+ |
|
|
254 |
+ |
#[test]
|
|
255 |
+ |
fn new_window_is_named_by_string_in_any_case() {
|
|
256 |
+ |
for spec in ["new-window", "New-Window", "NEW-WINDOW"] {
|
|
257 |
+ |
let t = table(&format!("runner = {spec:?}"));
|
|
258 |
+ |
assert_eq!(Runner::from_table(&t), Runner::NewWindow, "{spec}");
|
|
259 |
+ |
}
|
|
260 |
+ |
}
|
|
261 |
+ |
|
|
262 |
+ |
#[test]
|
|
263 |
+ |
fn an_argv_array_is_taken_as_written() {
|
|
264 |
+ |
let t = table(r#"runner = ["hx", "{file}"]"#);
|
|
265 |
+ |
assert_eq!(
|
|
266 |
+ |
Runner::from_table(&t),
|
|
267 |
+ |
Runner::Argv(vec!["hx".into(), "{file}".into()]),
|
|
268 |
+ |
);
|
|
269 |
+ |
}
|
|
270 |
+ |
|
|
271 |
+ |
#[test]
|
|
272 |
+ |
fn a_bad_runner_falls_back_rather_than_refusing_to_start() {
|
|
273 |
+ |
// Each of these is a config mistake; none of them may cost the user
|
|
274 |
+ |
// their terminal.
|
|
275 |
+ |
for src in [
|
|
276 |
+ |
r#"runner = "hx""#, // a bare program, not new-window
|
|
277 |
+ |
"runner = 7", // not a string or array
|
|
278 |
+ |
"runner = []", // empty
|
|
279 |
+ |
r#"runner = ["hx", 7]"#, // not all strings
|
|
280 |
+ |
] {
|
|
281 |
+ |
assert_eq!(Runner::from_table(&table(src)), Runner::NewWindow, "{src}");
|
|
282 |
+ |
}
|
|
283 |
+ |
}
|
|
284 |
+ |
|
|
285 |
+ |
#[test]
|
|
286 |
+ |
fn a_runner_without_the_placeholder_is_still_honoured() {
|
|
287 |
+ |
// Warned about, because it is nearly always a typo, but a runner that
|
|
288 |
+ |
// reads a fixed path is the user's business.
|
|
289 |
+ |
let t = table(r#"runner = ["true"]"#);
|
|
290 |
+ |
assert_eq!(Runner::from_table(&t), Runner::Argv(vec!["true".into()]));
|
|
291 |
+ |
}
|
|
292 |
+ |
|
|
293 |
+ |
#[test]
|
|
294 |
+ |
fn the_placeholder_is_substituted_in_every_element() {
|
|
295 |
+ |
let runner = Runner::Argv(vec!["a".into(), "{file}".into(), "{file}.bak".into()]);
|
|
296 |
+ |
let argv = runner.argv(Path::new("/run/x.txt")).unwrap();
|
|
297 |
+ |
assert_eq!(argv, ["a", "/run/x.txt", "/run/x.txt.bak"]);
|
|
298 |
+ |
}
|
|
299 |
+ |
|
|
300 |
+ |
#[test]
|
|
301 |
+ |
fn the_placeholder_substitutes_inside_a_larger_argument() {
|
|
302 |
+ |
// The tmux form: the placeholder sits inside the string passed to -E.
|
|
303 |
+ |
let runner = Runner::Argv(vec![
|
|
304 |
+ |
"tmux".into(),
|
|
305 |
+ |
"display-popup".into(),
|
|
306 |
+ |
"-E".into(),
|
|
307 |
+ |
"hx {file}".into(),
|
|
308 |
+ |
]);
|
|
309 |
+ |
let argv = runner.argv(Path::new("/run/x.txt")).unwrap();
|
|
310 |
+ |
assert_eq!(argv[3], "hx /run/x.txt");
|
|
311 |
+ |
}
|
|
312 |
+ |
|
|
313 |
+ |
#[test]
|
|
314 |
+ |
fn new_window_opens_shop_on_the_file() {
|
|
315 |
+ |
let argv = Runner::NewWindow.argv(Path::new("/run/x.txt")).unwrap();
|
|
316 |
+ |
assert!(argv[0].contains("shop"), "spawns shop itself: {argv:?}");
|
|
317 |
+ |
assert_eq!(argv[1], "-e");
|
|
318 |
+ |
assert_eq!(argv[3], "/run/x.txt");
|
|
319 |
+ |
}
|
|
320 |
+ |
|
|
321 |
+ |
#[test]
|
|
322 |
+ |
fn a_capture_reads_back_exactly() {
|
|
323 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
324 |
+ |
let path = write_capture(dir.path(), "hello\n", 1234, 7).unwrap();
|
|
325 |
+ |
assert!(path.starts_with(dir.path()), "{}", path.display());
|
|
326 |
+ |
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello\n");
|
|
327 |
+ |
}
|
|
328 |
+ |
|
|
329 |
+ |
#[test]
|
|
330 |
+ |
fn two_captures_from_one_window_do_not_collide() {
|
|
331 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
332 |
+ |
let a = write_capture(dir.path(), "one", 1234, 1).unwrap();
|
|
333 |
+ |
let b = write_capture(dir.path(), "two", 1234, 2).unwrap();
|
|
334 |
+ |
assert_ne!(a, b);
|
|
335 |
+ |
assert_eq!(std::fs::read_to_string(&a).unwrap(), "one");
|
|
336 |
+ |
assert_eq!(std::fs::read_to_string(&b).unwrap(), "two");
|
|
337 |
+ |
}
|
|
338 |
+ |
|
|
339 |
+ |
#[test]
|
|
340 |
+ |
fn two_windows_do_not_collide() {
|
|
341 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
342 |
+ |
let a = write_capture(dir.path(), "one", 1, 1).unwrap();
|
|
343 |
+ |
let b = write_capture(dir.path(), "two", 2, 1).unwrap();
|
|
344 |
+ |
assert_ne!(a, b);
|
|
345 |
+ |
}
|
|
346 |
+ |
|
|
347 |
+ |
#[test]
|
|
348 |
+ |
fn the_capture_dir_is_absolute() {
|
|
349 |
+ |
// XDG_RUNTIME_DIR is only honoured when absolute; the temp-dir fallback
|
|
350 |
+ |
// always is. A relative path would put captures wherever shop happened
|
|
351 |
+ |
// to be started from.
|
|
352 |
+ |
assert!(capture_dir().is_absolute());
|
|
353 |
+ |
}
|
|
354 |
+ |
}
|