Skip to main content

max / shop

Answer --help and --version instead of opening a terminal argv was four independent scans for -e, --exec, --record and --theme, and anything they did not match fell through to the shell spawn. So `shop --version` opened a window, and a typo'd flag in a desktop entry looked like the flag had been honoured and done nothing. Parse in one pass instead, and refuse what we do not have. -e still takes the rest of the line verbatim, which is why parsing stops there rather than continuing: --theme after -e is helix's argument, not ours. -e with nothing after it stays a shell rather than becoming an error, since that is a launcher that built a command line and found no command. Help and version print before the tracing subscriber is installed, so the answer is the only thing on stdout. The module header comes with it: it described the PTY-loop milestone and said there was no VT parser, which stopped being true long before this.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 17:51 UTC
Signed with PGP, not checked
Commit: d82de51f41f0f6b4c39ab0530e42824a452155e5
Parent: 56619e6
2 files changed, +238 insertions, -46 deletions
M Cargo.lock +14 -6
@@ -2150,7 +2150,7 @@
2150 2150
2151 2151 [[patch.unused]]
2152 2152 name = "docengine"
2153 - version = "0.5.0"
2153 + version = "0.7.0"
2154 2154
2155 2155 [[patch.unused]]
2156 2156 name = "synckit-client"
@@ -2162,15 +2162,19 @@
2162 2162
2163 2163 [[patch.unused]]
2164 2164 name = "quasi-axum"
2165 - version = "0.1.0"
2165 + version = "0.3.0"
2166 2166
2167 2167 [[patch.unused]]
2168 2168 name = "quasi-http"
2169 - version = "0.1.0"
2169 + version = "0.3.0"
2170 +
2171 + [[patch.unused]]
2172 + name = "quasi-immediate"
2173 + version = "0.3.0"
2170 2174
2171 2175 [[patch.unused]]
2172 2176 name = "quasi-router"
2173 - version = "0.1.0"
2177 + version = "0.3.0"
2174 2178
2175 2179 [[patch.unused]]
2176 2180 name = "quasi-store"
@@ -2178,16 +2182,20 @@
2178 2182
2179 2183 [[patch.unused]]
2180 2184 name = "quasi-tauri"
2181 - version = "0.1.0"
2185 + version = "0.3.0"
2182 2186
2183 2187 [[patch.unused]]
2184 2188 name = "quasi-webview"
2185 - version = "0.1.0"
2189 + version = "0.3.0"
2186 2190
2187 2191 [[patch.unused]]
2188 2192 name = "kberg"
2189 2193 version = "0.1.0"
2190 2194
2195 + [[patch.unused]]
2196 + name = "ops-status"
2197 + version = "0.1.0"
2198 +
2191 2199 [[patch.unused]]
2192 2200 name = "painhours"
2193 2201 version = "0.1.0"
@@ -1,10 +1,15 @@
1 1 //! shop — Wayland terminal emulator.
2 2 //!
3 - //! Milestone: PTY loop. calloop-driven event loop with two sources — the
4 - //! wayland event queue and the PTY master fd. Bytes read from the shell are
5 - //! appended to a rolling buffer and rendered as raw text (no VT parser yet,
6 - //! so escape sequences will show as garbage — vim/htop won't look right, but
7 - //! `ls`, `echo`, `printf` will).
3 + //! calloop-driven event loop with two sources — the wayland event queue and
4 + //! the PTY master fd. Bytes read from the shell go through `shop_vt` into a
5 + //! `shop_grid::Grid`, which `shop_render` draws.
6 + //!
7 + //! <!-- wiki: shop-overview -->
8 + //!
9 + //! This header used to describe the PTY-loop milestone and say there was no
10 + //! VT parser yet. That stopped being true long before anyone corrected it,
11 + //! which is the failure mode a status line in a doc comment invites: it is
12 + //! read on the way past and updated by nobody.
8 13
9 14 use std::os::fd::{AsFd, AsRawFd};
10 15 use std::sync::Arc;
@@ -116,7 +121,119 @@
116 121 }
117 122 }
118 123
124 + /// What argv asked the terminal to do, once it is known to be a request to
125 + /// open a window at all.
126 + #[derive(Debug, Default, PartialEq, Eq)]
127 + struct Cli {
128 + /// `--exec CMD` runs `sh -c CMD` instead of the interactive shell — useful
129 + /// for benchmarks and one-shot invocations. Shop exits when the child
130 + /// exits (PTY EOF triggers the read=0 branch).
131 + exec_cmd: Option<String>,
132 + /// `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
133 + /// arguments, with no shell in between. This is the xterm convention, and
134 + /// it is not optional for a terminal that means to be somebody's
135 + /// `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
136 + /// that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
137 + /// ignores it opens a bare shell and looks like the launcher is broken.
138 + ///
139 + /// Distinct from `--exec` on purpose: that one is a single string handed to
140 + /// `sh -c`, which is what a benchmark wants and what a launcher must not
141 + /// have, because the arguments would need quoting nobody applies.
142 + exec_argv: Option<Vec<String>>,
143 + /// `--record PATH` tees the PTY output byte-for-byte to PATH, for
144 + /// feeding into the kitty-graphics-testkit corpus via capture-apc.
145 + record_path: Option<String>,
146 + /// `--theme ID` overrides the config file for one run, which is how you look
147 + /// at a theme before committing to it.
148 + theme: Option<String>,
149 + }
150 +
151 + /// Every way argv can end the process before a window exists.
152 + #[derive(Debug, PartialEq, Eq)]
153 + enum CliExit {
154 + /// Asked a question we can answer on stdout. Exit 0.
155 + Answer(String),
156 + /// Asked for something we do not have. Exit 2, message on stderr.
157 + Reject(String),
158 + }
159 +
160 + const HELP: &str = "\
161 + shop, a Wayland terminal emulator.
162 +
163 + Usage: shop [OPTIONS] [-e PROGRAM [ARGS...]]
164 +
165 + Options:
166 + -e PROGRAM [ARGS...] Run PROGRAM with no shell in between. Takes the rest
167 + of the command line, so it goes last.
168 + --exec CMD Run CMD through sh -c instead of the login shell.
169 + --theme ID Use theme ID for this run, ignoring the config file.
170 + --record PATH Tee the terminal output to PATH as raw bytes.
171 + -h, --help Print this help.
172 + -V, --version Print the version.
173 +
174 + Config is read from ~/.config/shop/config.toml.";
175 +
176 + /// Read argv, or say why we are not opening a window.
177 + ///
178 + /// Written by hand rather than with a parser crate because the grammar is four
179 + /// flags and one convention, and the one convention is the part a crate gets
180 + /// wrong: `-e` swallows the remainder of the command line, child flags and all,
181 + /// so nothing after it is ours to interpret.
182 + ///
183 + /// The rejection half is the point. Silently ignoring an unknown flag means
184 + /// `shop --version` opens a terminal, and a typo'd `--theme` in a desktop entry
185 + /// looks like the theme is broken rather than like the entry is.
186 + fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, CliExit> {
187 + let mut cli = Cli::default();
188 + let mut args = args.into_iter().skip(1);
189 + while let Some(arg) = args.next() {
190 + // Takes the rest verbatim, so stop reading argv as ours here. Empty is
191 + // not an error: `-e` with nothing after it is a launcher that built a
192 + // command line and found no command, and a shell is the useful answer.
193 + if arg == "-e" {
194 + let argv: Vec<String> = args.by_ref().collect();
195 + cli.exec_argv = Some(argv).filter(|a| !a.is_empty());
196 + return Ok(cli);
197 + }
198 + let mut value = |flag: &str| {
199 + args.next()
200 + .ok_or_else(|| CliExit::Reject(format!("{flag} needs a value")))
201 + };
202 + match arg.as_str() {
203 + "-h" | "--help" => return Err(CliExit::Answer(HELP.into())),
204 + "-V" | "--version" => {
205 + return Err(CliExit::Answer(format!(
206 + "shop {}",
207 + env!("CARGO_PKG_VERSION")
208 + )));
209 + }
210 + "--exec" => cli.exec_cmd = Some(value("--exec")?),
211 + "--theme" => cli.theme = Some(value("--theme")?),
212 + "--record" => cli.record_path = Some(value("--record")?),
213 + other => {
214 + return Err(CliExit::Reject(format!(
215 + "unknown option {other}\nTry 'shop --help'."
216 + )));
217 + }
218 + }
219 + }
220 + Ok(cli)
221 + }
222 +
119 223 fn main() -> anyhow::Result<()> {
224 + // Before the subscriber, so `shop --help` prints help and nothing else.
225 + let cli = match parse_args(std::env::args()) {
226 + Ok(cli) => cli,
227 + Err(CliExit::Answer(text)) => {
228 + println!("{text}");
229 + return Ok(());
230 + }
231 + Err(CliExit::Reject(message)) => {
232 + eprintln!("shop: {message}");
233 + std::process::exit(2);
234 + }
235 + };
236 +
120 237 tracing_subscriber::fmt()
121 238 .with_env_filter(
122 239 tracing_subscriber::EnvFilter::try_from_default_env()
@@ -126,41 +243,12 @@
126 243
127 244 let font_data: Vec<u8> = FONT_BYTES.to_vec();
128 245
129 - // `--exec CMD` runs `sh -c CMD` instead of the interactive shell — useful
130 - // for benchmarks and one-shot invocations. Shop exits when the child
131 - // exits (PTY EOF triggers the read=0 branch).
132 - let args: Vec<String> = std::env::args().collect();
133 - let exec_cmd = args
134 - .iter()
135 - .position(|a| a == "--exec")
136 - .and_then(|i| args.get(i + 1).cloned());
137 - // `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
138 - // arguments, with no shell in between. This is the xterm convention, and
139 - // it is not optional for a terminal that means to be somebody's
140 - // `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
141 - // that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
142 - // ignores it opens a bare shell and looks like the launcher is broken.
143 - //
144 - // Distinct from `--exec` on purpose: that one is a single string handed to
145 - // `sh -c`, which is what a benchmark wants and what a launcher must not
146 - // have, because the arguments would need quoting nobody applies.
147 - let exec_argv: Option<Vec<String>> = args
148 - .iter()
149 - .position(|a| a == "-e")
150 - .map(|i| args[i + 1..].to_vec())
151 - .filter(|argv| !argv.is_empty());
152 - // `--record PATH` tees the PTY output byte-for-byte to PATH, for
153 - // feeding into the kitty-graphics-testkit corpus via capture-apc.
154 - let record_path = args
155 - .iter()
156 - .position(|a| a == "--record")
157 - .and_then(|i| args.get(i + 1).cloned());
158 - // `--theme ID` overrides the config file for one run, which is how you look
159 - // at a theme before committing to it.
160 - let theme_arg = args
161 - .iter()
162 - .position(|a| a == "--theme")
163 - .and_then(|i| args.get(i + 1).cloned());
246 + let Cli {
247 + exec_cmd,
248 + exec_argv,
249 + record_path,
250 + theme: theme_arg,
251 + } = cli;
164 252 let config = Config::load().with_theme(theme_arg);
165 253 let scrollback_lines = config.scrollback_lines;
166 254 let bindings = config.bindings.clone();
@@ -2335,6 +2423,102 @@
2335 2423 assert_eq!(wheel_action(0, true, true, NORMAL), WheelAction::Nothing);
2336 2424 }
2337 2425
2426 + /// argv as the process actually receives it, program name and all.
2427 + fn cmdline(rest: &[&str]) -> Vec<String> {
2428 + std::iter::once("shop".to_string())
2429 + .chain(rest.iter().copied().map(String::from))
2430 + .collect()
2431 + }
2432 +
2433 + #[test]
2434 + fn a_bare_invocation_asks_for_nothing() {
2435 + assert_eq!(parse_args(cmdline(&[])), Ok(Cli::default()));
2436 + }
2437 +
2438 + #[test]
2439 + fn help_and_version_answer_instead_of_opening_a_window() {
2440 + // The defect this exists for: both used to fall through the argv scan
2441 + // and spawn a shell, so `shop --version` opened a terminal.
2442 + for flag in ["-h", "--help"] {
2443 + assert_eq!(
2444 + parse_args(cmdline(&[flag])),
2445 + Err(CliExit::Answer(HELP.into()))
2446 + );
2447 + }
2448 + let version = Err(CliExit::Answer(format!(
2449 + "shop {}",
2450 + env!("CARGO_PKG_VERSION")
2451 + )));
2452 + for flag in ["-V", "--version"] {
2453 + assert_eq!(parse_args(cmdline(&[flag])), version);
2454 + }
2455 + }
2456 +
2457 + #[test]
2458 + fn an_unknown_option_is_refused_by_name() {
2459 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--colour", "red"])) else {
2460 + panic!("an unknown option must not open a window");
2461 + };
2462 + assert!(message.contains("--colour"), "{message}");
2463 + }
2464 +
2465 + #[test]
2466 + fn a_flag_with_no_value_is_refused() {
2467 + for flag in ["--exec", "--theme", "--record"] {
2468 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&[flag])) else {
2469 + panic!("{flag} without a value must not open a window");
2470 + };
2471 + assert!(message.contains(flag), "{message}");
2472 + }
2473 + }
2474 +
2475 + #[test]
2476 + fn the_value_flags_read_their_values() {
2477 + let cli = parse_args(cmdline(&[
2478 + "--theme",
2479 + "akari-night",
2480 + "--exec",
2481 + "ls | wc -l",
2482 + "--record",
2483 + "/tmp/out.bin",
2484 + ]))
2485 + .expect("every flag here is one we have");
2486 + assert_eq!(cli.theme.as_deref(), Some("akari-night"));
2487 + assert_eq!(cli.exec_cmd.as_deref(), Some("ls | wc -l"));
2488 + assert_eq!(cli.record_path.as_deref(), Some("/tmp/out.bin"));
2489 + assert_eq!(cli.exec_argv, None);
2490 + }
2491 +
2492 + #[test]
2493 + fn dash_e_takes_the_rest_of_the_line_including_flags_we_know() {
2494 + // The reason parsing stops at -e rather than continuing: --theme here
2495 + // is helix's argument, not ours, and rejecting or eating it would
2496 + // break every `$TERMINAL -e prog --flag` in the desktop.
2497 + let cli = parse_args(cmdline(&["-e", "helix", "--theme", "dark", "f.rs"]))
2498 + .expect("-e swallows the remainder");
2499 + assert_eq!(
2500 + cli.exec_argv,
2501 + Some(argv(&["helix", "--theme", "dark", "f.rs"]))
2502 + );
2503 + assert_eq!(cli.theme, None);
2504 + }
2505 +
2506 + #[test]
2507 + fn dash_e_with_nothing_after_it_is_a_shell_not_an_error() {
2508 + // A launcher that built a command line and found no command. Opening a
2509 + // shell is more useful than refusing, and it is what the old scan did.
2510 + let cli = parse_args(cmdline(&["-e"])).expect("-e alone is not a refusal");
2511 + assert_eq!(cli.exec_argv, None);
2512 + }
2513 +
2514 + #[test]
2515 + fn flags_before_dash_e_are_still_ours() {
2516 + let cli = parse_args(cmdline(&["--theme", "akari-night", "-e", "btop"]))
2517 + .expect("both halves parse");
2518 + assert_eq!(cli.theme.as_deref(), Some("akari-night"));
2519 + assert_eq!(cli.exec_argv, Some(argv(&["btop"])));
2520 + }
2521 +
2338 2522 #[test]
2339 2523 fn with_no_flags_the_login_shell_runs() {
2340 2524 let (cmd, args) = spawn_target(None, None, Some("/usr/bin/nu".into()));