diff --git a/.claude/skills/test-tui/SKILL.md b/.claude/skills/test-tui/SKILL.md index a095347..7aec39c 100644 --- a/.claude/skills/test-tui/SKILL.md +++ b/.claude/skills/test-tui/SKILL.md @@ -130,6 +130,6 @@ rm -f /tmp/scope_a /tmp/scope_b /tmp/scope_capture.bin \ ## Notes -- Subcommands: `scope serial [PORT] [BAUDRATE]`, also `ble`, `rtt`, `list`. Global opts: `-t/--tag-file`, `-c/--capacity`, `-l/--latency`. +- Subcommands: `scope serial [PORT] [BAUDRATE]`, also `ble`, `rtt`, `list`, `completions`. Global opts: `-t/--tag-file`, `-c/--capacity`, `-l/--latency`. - Add small `sleep`s after each action so the TUI redraws before you capture. - The input bar does not colorize hex/tag sequences while typing (by design); parsing and highlighting happen on send and render in the output area. diff --git a/CLAUDE.md b/CLAUDE.md index eb70b5e..8448142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,12 +19,16 @@ cargo run --bin scope -- rtt # RTT via probe-rs cargo test --bin scope # run unit tests cargo test --bin scope # run a single test, e.g. cargo test --bin scope test_rhs cargo test --test tui_e2e # run the end-to-end TUI tests (Unix only) +cargo test --test completions # shell-completion tests (a missing shell skips, never fails) + +cargo run --bin scope -- completions zsh # emit a shell completion script ``` - This is a **binary-only crate** (no lib target). Use `cargo test --bin scope` — `cargo test --lib` fails with "no library targets". Unit tests live in `#[cfg(test)] mod tests` blocks inside the source files they cover. - **End-to-end TUI tests** are in `tests/tui_e2e.rs` (Unix only): they spawn the real binary in a PTY (`portable-pty`), connect it to a virtual serial port (`openpty`), inject keystrokes, and assert on the screen reconstructed by a `vt100` parser. The serial-RX test is `#[ignore]`d because byte transport over a PTY-backed serial port is platform dependent (`serialport` can't set baud via ioctl on a macOS PTY); run it with `cargo test --test tui_e2e -- --ignored`. - `src/main.rs` has `#![deny(warnings)]`, so any compiler warning fails the build. Keep the tree warning-clean. -- Global CLI options (before the subcommand): `-c/--capacity` (scrollback lines, default 2000), `-t/--tag-file` (default `tags.yml`), `-l/--latency` (ms, clamped 0..=100000, default 100), `-n/--name` (session record base name, default a timestamp), `--headless` (see below). The session can also be renamed at runtime with `!rename ` in the command bar. +- Global CLI options (before the subcommand): `-c/--capacity` (scrollback lines, default 2000), `-t/--tag-file` (default `tags.yml`), `-l/--latency` (**microseconds** — every polling loop but `rtt_if::wait` treats it as µs, see below — clamped 0..=100000, default 100), `-n/--name` (session record base name, default a timestamp), `--headless` (see below). The session can also be renamed at runtime with `!rename ` in the command bar. +- **Shell completions** (issue #231): `scope completions ` prints a native `clap_complete` **static** (ahead-of-time) script to stdout (`bash`/`zsh`/`fish`/`powershell`/`elvish`); the shell is a required positional, so we never guess from `$SHELL` (that's the *login* shell). Two non-obvious constraints, both pinned by `tests/completions.rs`: (1) `Cli` carries an explicit `#[command(name = "scope")]`, because `clap_derive` otherwise names the command from `CARGO_PKG_NAME` (`scope-monitor`) and the script registers a command nobody runs; (2) the arm is dispatched in `main` **right after `Cli::parse()`, before the `Config::load()` closure**, and returns early — the script is evaluated on every shell start-up, so it must not read `config.toml` (a typo there would break the user's prompt, not just scope) and must not reach the `println!("See you later ^^")` epilogue, which the shell would try to execute. The `Commands::Completions { .. } => unreachable!()` arm inside the closure exists only to keep the match exhaustive. `clap_complete` needs `clap >= 4.5.20` (hence the manifest bump) and adds one crate. Deliberately **not** `unstable-dynamic`: it is semver-exempt, its bash/fish hooks drop the file-name fallback for `--tag-file`, and live port values would come from the USB-only `list::usb_ports` with no fallback — live value completion is a follow-up. Install instructions live in README.md → *Installation → Shell completions* and in `scope completions --help`. - **Icon mode** (`src/selector.rs`, issues #229/#230): when `serial`/`rtt` is launched **without** its positional args (`scope serial`, `scope --headless rtt`, …), `main` runs an interactive ratatui picker *before* spawning any task, to choose port+baud (serial: a list from `list::usb_ports`) or target+channel (rtt: text fields, since a chip name isn't enumerable). `resolve_serial`/`resolve_rtt` in `main.rs` gate it: only when the missing arg *and* an interactive terminal (`stdin/stdout.is_terminal()`) are present — piped/scripted runs fall through to the old "start disconnected" behaviour, as does the picker's `Skip` (`s`). `Quit` (`q`/`Esc`) exits before the app starts. The picker owns its own crossterm session (raw mode + alternate screen) and restores the terminal via `Tui`'s `Drop`. Pure state logic (`move_selection`, `parse_baud`, `parse_channel`, `initial_baud_index`) is unit-tested; the render/loop is exercised via the `test-tui` skill. - **Headless mode** (`--headless`): no TUI — a raw terminal↔wire bridge. A `graphics/headless.rs` task takes the graphics slot (same `GraphicsCommand` channel + tx/rx/logger consumers) and just writes RX bytes to stdout (logs colored via ANSI, no timestamps/scrollback/persistence). The Inputs task carries a `raw: bool` overlay on `InputsShared` (not a new `InputMode`): raw keys are encoded to VT bytes (`inputs/key_encode.rs`) and sent straight to `tx`; `Ctrl+K` drops into the existing `Normal` command bar (blinking `> ` prompt rendered by the headless task), Enter runs the command and returns to raw, Esc quits. The interface tasks forward RX immediately (per-byte / per-chunk) instead of `\n`-framing when `headless` is set. - **Periodic full repaint** (issues #166/#233): a 3s `Timer` in the graphics draw loop forces a whole-screen repaint (`graphics/graphics_task.rs`, `force_full_repaint`) so a screen cleared from outside the app (e.g. Cmd+K in Zed's terminal) heals itself. It must **not** call `terminal.clear()`: crossterm's `execute!` flushes `ESC[2J` on a write of its own, so the terminal shows a blank frame ~2ms before the repaint arrives — that was the 1Hz blink of #233. It must **not** use `terminal.swap_buffers()` alone either: a reset diff base equals a blank cell, so ratatui skips every cell that is blank in the new frame and strands stale glyphs forever. Instead the back buffer is filled with a sentinel cell and promoted to the diff base with `swap_buffers`, so the next `draw` rewrites every cell (blanks included) in its normal flush — no erase byte is ever emitted. Rewriting the blanks makes a forced frame ~3x bigger, which is what the 3s period pays for: measured idle output is ~2.4KB/s at 160x40, just under the ~2.7KB/s the old 1s clear+repaint cost. A genuine resize still clears, inside ratatui's `Terminal::resize`. Two e2e tests pin both halves (`periodic_repaint_never_erases_the_display`, `periodic_repaint_overwrites_external_garbage`); `screen_recovers_after_external_clear` alone passes even for the two broken variants. diff --git a/Cargo.lock b/Cargo.lock index b88434c..842a728 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,9 +392,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.13" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -402,9 +402,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.13" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -412,11 +412,20 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -426,9 +435,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -2403,6 +2412,7 @@ dependencies = [ "arboard", "chrono", "clap", + "clap_complete", "crossterm", "ctrlc", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 26bbb1e..ca8c587 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,8 @@ serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" toml = "0.8" rand = "0.8.6" -clap = { version = "4.1.9", features = ["derive"] } +# 4.5.20 is the floor `clap_complete` requires. +clap = { version = "4.5.20", features = ["derive"] } mlua = { version = "0.9.6", features = ["lua54", "vendored", "async", "send"] } anyhow = "1.0.79" dirs = "5" @@ -55,6 +56,10 @@ lipsum = "0.9.1" arboard = "3.6.1" probe-rs = "0.31.0" nucleo = "0.5.0" +# Generates the native shell completion scripts `scope completions ` +# prints (issue #231). Static (ahead-of-time) generation only — the dynamic +# completion API is behind a semver-exempt feature flag. +clap_complete = "4.6.8" [target.'cfg(windows)'.dependencies] ctrlc = "3.4.3" @@ -63,11 +68,14 @@ ctrlc = "3.4.3" [target.'cfg(windows)'.build-dependencies] winresource = "0.1" +# Used by `tests/completions.rs`, which runs on every platform. +[dev-dependencies] +tempfile = "3" + # Used only by the end-to-end TUI integration tests in `tests/tui_e2e.rs`. [target.'cfg(unix)'.dev-dependencies] portable-pty = "0.8" vt100 = "0.15" -tempfile = "3" nix = { version = "0.29", features = ["term"] } # The profile that 'dist' will build with diff --git a/README.md b/README.md index 905c11a..3ea84b3 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,46 @@ cargo build --release # the binary is at target/release/scope ``` +### Shell completions + +`scope completions ` prints a native completion script to stdout, so that `scope se` + Tab completes to `scope serial` using your shell's own completion engine. `bash`, `zsh`, `fish`, `powershell` and `elvish` are supported. No installer sets this up for you — install it once: + +- **bash (Linux)** — drop it in the user completion directory (needs the `bash-completion` package): + ```shell + mkdir -p ~/.local/share/bash-completion/completions + scope completions bash > ~/.local/share/bash-completion/completions/scope + ``` +- **bash (macOS)** — the system bash is 3.2 and Terminal starts it as a *login* shell, so that directory is never read. Write the script somewhere and source it from **`~/.bash_profile`** (not `~/.bashrc`): + ```shell + scope completions bash > ~/.scope-completion.bash + echo 'source ~/.scope-completion.bash' >> ~/.bash_profile + ``` +- **zsh** — the file must be named `_scope`, and `fpath` must be extended **before** `compinit` runs: + ```shell + mkdir -p ~/.zfunc + scope completions zsh > ~/.zfunc/_scope + ``` + then, in `~/.zshrc`: + ```shell + fpath=(~/.zfunc $fpath) + autoload -Uz compinit && compinit + ``` +- **fish** — nothing else to configure, fish autoloads it: + ```shell + mkdir -p ~/.config/fish/completions + scope completions fish > ~/.config/fish/completions/scope.fish + ``` +- **PowerShell (Windows)** — write the script next to your profile and dot-source it: + ```powershell + if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null } + $comp = Join-Path (Split-Path $PROFILE) '_scope.ps1' + scope completions powershell > $comp + Add-Content $PROFILE ('. "' + $comp + '"') + ``` + `$PROFILE` is `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` on PowerShell 7 and `Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` on Windows PowerShell 5.1, which also defaults to a `Restricted` execution policy that blocks profile scripts entirely — allow them once with `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`. `cmd.exe` has no completion engine and is not supported. + +Restart your shell afterwards (zsh: or re-run `compinit`). `scope completions --help` prints the same commands, so you don't need this page on the machine you are installing on. + ## Quickstart Open a serial port by passing it to the `serial` subcommand together with the baud rate: @@ -316,6 +356,7 @@ Commands: | `rtt [] []` | Attach to an RTT target via `probe-rs` (e.g. `scope rtt STM32F303 0`). | | `list [-v\|--verbose]` | List the available serial ports. | | `ble ` | *(Not yet implemented.)* | +| `completions ` | Print a shell completion script for `scope` (`bash`, `zsh`, `fish`, `powershell`, `elvish`) — see [Shell completions](#shell-completions). | Global options (given before the command): @@ -413,6 +454,8 @@ Load a plugin with `!plugin load ` (and `!plugin reload ` / `!plugin **My `tag_file` / config path isn't found.** Path values are used verbatim: `~` and environment variables are **not** expanded. Use an absolute path (for example `/home/user/.config/scope/tags.yml`). +**Tab completion doesn't work.** Completions are not installed automatically — run `scope completions ` as described in [Shell completions](#shell-completions), then start a new shell. Two mistakes fail silently: on zsh, `fpath` must be extended *before* `compinit` runs (oh-my-zsh calls `compinit` for you, so the `fpath=(~/.zfunc $fpath)` line has to come above the `oh-my-zsh.sh` source); on macOS bash, the `source` line has to be in `~/.bash_profile`, since Terminal starts a login shell that never reads `~/.bashrc`. + ## Project Goals This project has 5 pillars that direct the development of this tool: diff --git a/src/main.rs b/src/main.rs index f34521d..1cd7646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,8 @@ use crate::infra::tags::TagList; use crate::interfaces::rtt_if::{RttCommand, RttConnections, RttSetup}; use crate::interfaces::serial_if::SerialCommand; use crate::interfaces::{InterfaceCommand, InterfaceTask, InterfaceType}; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::aot::{Shell, generate}; use graphics::graphics_task::{GraphicsConnections, GraphicsTask}; use infra::config::Config; use infra::logger::Logger; @@ -35,14 +36,26 @@ const DEFAULT_CAPACITY: usize = 2000; const DEFAULT_TAG_FILE: &str = "tags.yml"; #[derive(Parser)] -#[command(author, version, about, long_about = None)] +// The name is spelled out because `clap_derive` would otherwise take it from +// `CARGO_PKG_NAME` (`scope-monitor`), and the completion scripts would then be +// registered for a command nobody runs — see `Commands::Completions`. +#[command(name = "scope", author, version, about, long_about = None)] +#[command(after_help = "Tip: run `scope completions --help` to enable \ +completion for scope in your shell.")] struct Cli { #[command(subcommand)] command: Commands, + /// Number of scrollback lines kept in memory. Falls back to `capacity` in + /// config.toml, then to 2000. #[clap(short, long)] capacity: Option, + /// Path to the YAML file whose entries resolve `@name` tags typed in the + /// command bar. Falls back to `tag_file` in config.toml, then to `tags.yml`. + /// Used verbatim: `~` and environment variables are not expanded. #[clap(short, long)] tag_file: Option, + /// Polling latency in microseconds, clamped to 0..=100000. Defaults to 100; + /// 0 yields the thread instead of sleeping. #[clap(short, long)] latency: Option, /// Base name for the session record file. Defaults to a timestamp. @@ -58,22 +71,54 @@ struct Cli { #[derive(Subcommand)] pub enum Commands { + /// Open a serial port. Without arguments, pick one interactively. Serial { + /// Serial port to open, e.g. /dev/ttyUSB0 or COM3. Run `scope list` to + /// see what is available. port: Option, + /// Baud rate to open the port at, e.g. 115200. baudrate: Option, }, + /// List the available serial ports. List { + /// Show one table row per USB port with its serial number, PID, VID and + /// manufacturer. Non-USB ports are omitted from this view. #[clap(short, long)] verbose: bool, }, + /// Connect to a BLE device (not yet implemented). Ble { + /// Advertised name of the BLE device to connect to. name_device: String, + /// ATT MTU to negotiate with the device. mtu: u32, }, + /// Attach to an RTT target via probe-rs. Without arguments, pick one + /// interactively. Rtt { + /// Target chip name as probe-rs spells it, e.g. STM32F303. target: Option, + /// RTT channel to attach to. Defaults to 0. channel_num: Option, }, + /// Print a shell completion script for `scope` to stdout. + /// + /// Install it once so that `scope se` completes to `scope serial`: + /// + /// bash scope completions bash > ~/.local/share/bash-completion/completions/scope + /// (macOS: write it to a file and `source` that from ~/.bash_profile) + /// zsh scope completions zsh > ~/.zfunc/_scope + /// (~/.zshrc needs `fpath=(~/.zfunc $fpath)` before `compinit`) + /// fish scope completions fish > ~/.config/fish/completions/scope.fish + /// powershell scope completions powershell >> $PROFILE + /// + /// Then restart your shell. See the README for the full instructions. + #[command(verbatim_doc_comment)] + Completions { + /// Shell to generate the completion script for. + #[clap(value_enum)] + shell: Shell, + }, } fn app_serial( @@ -413,6 +458,22 @@ fn main() -> Result<(), String> { let cli = Cli::parse(); + // Emitting a completion script is pure output, and the shell evaluates it on + // every start-up — so it is handled before the fallible setup below and + // returns straight away: no `config.toml` (a typo there would break the + // user's prompt, not just scope), no keymap, no picker, and crucially not + // the `See you later ^^` epilogue, which the shell would try to run. + // Emitting a completion script is pure output, and the shell evaluates it on + // every start-up — so it is handled before the fallible setup below and + // returns straight away: no `config.toml` (a typo there would break the + // user's prompt, not just scope), no keymap, no picker, and crucially not + // the `See you later ^^` epilogue, which the shell would try to run. + if let Commands::Completions { shell } = &cli.command { + let mut cmd = Cli::command(); + generate(*shell, &mut cmd, "scope", &mut stdout()); + return Ok(()); + } + let latency = cli.latency.unwrap_or(100).clamp(0, 100_000); // Everything that can fail fatally — loading `~/.config/scope/config.toml`, @@ -467,6 +528,9 @@ fn main() -> Result<(), String> { ), None => Ok(()), }, + // Handled right after `Cli::parse()`, before this closure, so a + // completion script never depends on the config loading. + Commands::Completions { .. } => unreachable!(), } })(); diff --git a/tests/completions.rs b/tests/completions.rs new file mode 100644 index 0000000..d9d133c --- /dev/null +++ b/tests/completions.rs @@ -0,0 +1,456 @@ +//! Shell-completion tests for `scope completions ` (issue #231). +//! +//! Two layers: +//! 1. Script tests — run `scope completions ` and assert on what it +//! prints. Portable: they need no shell at all, so they also guard Windows +//! CI. These pin the two ways the subcommand can silently ship broken: the +//! `See you later ^^` epilogue leaking into a script the shell sources, and +//! the command being named after the *package* (`scope-monitor`) instead of +//! the binary, which makes the completion never fire. +//! 2. Shell tests — actually ask bash / fish / zsh / PowerShell to complete +//! `scope se`, which is the acceptance criterion of the issue. Each is +//! skipped when its shell is missing (no CI runner has all four), so they +//! never fail for want of a shell. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Every shell `scope completions` advertises. +const SHELLS: [&str; 5] = ["bash", "elvish", "fish", "powershell", "zsh"]; + +/// The script `scope completions ` prints, or a panic with its stderr. +fn script(shell: &str) -> String { + let out = Command::new(env!("CARGO_BIN_EXE_scope")) + .args(["completions", shell]) + .output() + .expect("spawn scope"); + assert!( + out.status.success(), + "`scope completions {shell}` failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.stderr.is_empty(), + "`scope completions {shell}` wrote to stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("script is UTF-8") +} + +// ------------------------------------------------------------------ scripts --- + +/// Every advertised shell yields a non-empty script that registers the +/// completion for `scope` — not for `scope-monitor`. `clap_derive` names the +/// command after `CARGO_PKG_NAME`, so without `#[command(name = "scope")]` the +/// generated script targets a binary that does not exist and Tab never fires. +#[test] +fn every_shell_emits_a_script_for_the_scope_binary() { + for shell in SHELLS { + let script = script(shell); + assert!(!script.trim().is_empty(), "{shell}: empty script"); + assert!( + script.contains("scope"), + "{shell}: script never names `scope`" + ); + assert!( + !script.contains("scope-monitor"), + "{shell}: script targets the package name, not the binary `scope`" + ); + } +} + +/// The script is the *only* thing on stdout. `main` prints `See you later ^^` +/// when a command returns, so `completions` has to bypass that epilogue: a +/// trailing `See you later ^^` is a syntax error in every shell that sources the +/// script, i.e. an error on every prompt. +#[test] +fn script_is_the_only_thing_on_stdout() { + for shell in SHELLS { + let script = script(shell); + assert!( + !script.contains("See you later"), + "{shell}: the `main` epilogue leaked into the completion script" + ); + } +} + +/// The script offers the whole CLI surface, so a new subcommand or global flag +/// can't quietly go missing from Tab. +#[test] +fn script_covers_the_whole_cli() { + let script = script("bash"); + for want in [ + "serial", + "rtt", + "list", + "ble", + "completions", + "--headless", + "--capacity", + "--tag-file", + "--latency", + "--name", + ] { + assert!(script.contains(want), "bash script never mentions {want}"); + } +} + +/// A shell we can't generate for is a hard error listing the ones we can, and +/// the argument is required — we never guess from `$SHELL` (which is the *login* +/// shell, so guessing would hand a zsh script to someone running bash). +#[test] +fn an_unknown_or_missing_shell_is_an_error() { + let out = Command::new(env!("CARGO_BIN_EXE_scope")) + .args(["completions", "clamshell"]) + .output() + .expect("spawn scope"); + assert!(!out.status.success(), "an unknown shell must be an error"); + let err = String::from_utf8_lossy(&out.stderr); + for want in ["clamshell", "bash", "zsh", "fish", "powershell"] { + assert!(err.contains(want), "error should mention {want}: {err}"); + } + + let out = Command::new(env!("CARGO_BIN_EXE_scope")) + .arg("completions") + .output() + .expect("spawn scope"); + assert!(!out.status.success(), "a missing shell must be an error"); + assert!( + out.stdout.is_empty(), + "a usage error must not print a half script" + ); +} + +/// A broken `config.toml` is fatal for every other command — but a completion +/// script is evaluated on every shell start-up, so `completions` must not read +/// the config at all. If it did, one typo in `config.toml` would break the +/// user's prompt instead of just `scope`. +/// +/// Unix-only: `dirs::config_dir()` follows `$HOME`/`$XDG_CONFIG_HOME` here, but +/// on Windows it asks the shell-known-folder API, which no env var can redirect. +#[cfg(unix)] +#[test] +fn a_broken_config_file_does_not_break_the_script() { + let home = tempfile::tempdir().expect("tempdir"); + for dir in ["Library/Application Support/scope", ".config/scope"] { + let dir = home.path().join(dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("config.toml"), "capacity = \"not a number\"\n").unwrap(); + } + + let out = Command::new(env!("CARGO_BIN_EXE_scope")) + .args(["completions", "bash"]) + .env("HOME", home.path()) + .env("XDG_CONFIG_HOME", home.path().join(".config")) + .output() + .expect("spawn scope"); + assert!( + out.status.success(), + "completions must ignore a malformed config: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + String::from_utf8_lossy(&out.stdout).contains("scope"), + "no script emitted with a malformed config present" + ); +} + +// ------------------------------------------------------------------- shells --- + +/// Absolute path of `prog` on `PATH`, or `None` — used to skip a shell test +/// instead of failing it when the shell isn't installed. +fn which(prog: &str) -> Option { + let sep = if cfg!(windows) { ';' } else { ':' }; + let exts: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ""] + } else { + &[""] + }; + std::env::var_os("PATH")? + .to_str()? + .split(sep) + .find_map(|dir| { + exts.iter() + .map(|ext| Path::new(dir).join(format!("{prog}{ext}"))) + .find(|p| p.is_file()) + }) +} + +/// A temp dir holding the completion script for `shell` plus a copy of the +/// binary on a private `PATH` — shells only complete commands they can find. +struct Staged { + /// Owns the staged files: dropping it deletes them, so it has to outlive the + /// shell. Only some of the shell tests read it, and which ones are compiled + /// depends on the platform, hence the `allow`. + #[allow(dead_code)] + dir: tempfile::TempDir, + path: String, + script: PathBuf, +} + +fn stage(shell: &str, script_name: &str) -> Staged { + let dir = tempfile::tempdir().expect("tempdir"); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::copy( + env!("CARGO_BIN_EXE_scope"), + bin.join(if cfg!(windows) { "scope.exe" } else { "scope" }), + ) + .expect("copy scope onto PATH"); + + let script_path = dir.path().join(script_name); + std::fs::create_dir_all(script_path.parent().unwrap()).unwrap(); + std::fs::write(&script_path, script(shell)).expect("write script"); + + let sep = if cfg!(windows) { ";" } else { ":" }; + let path = format!( + "{}{sep}{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + Staged { + dir, + path, + script: script_path, + } +} + +/// bash completion is just a shell function, so a non-interactive bash can +/// source the script and call it the way readline does — ` +/// ` in, `COMPREPLY` out. Works on the bash 3.2 macOS still ships. +/// +/// Unix-only on purpose: the `bash` on `windows-latest` is Git-bash, whose path +/// translation makes this brittle for no gain — PowerShell covers Windows. +#[cfg(not(windows))] +#[test] +fn bash_completes_se_to_serial() { + let Some(bash) = which("bash") else { + return eprintln!("skip: no bash"); + }; + let staged = stage("bash", "scope.bash"); + // The function name is clap_complete's business: read it off the + // `complete -F scope` line so a rename can't silently pass this test. + let script = std::fs::read_to_string(&staged.script).unwrap(); + let func = script + .split_whitespace() + .skip_while(|w| *w != "-F") + .nth(1) + .expect("no `complete -F ` line in the bash script"); + + let driver = format!( + r#" + source "{script}" + COMP_LINE='scope se'; COMP_POINT=8; COMP_TYPE=9 + COMP_WORDS=(scope se); COMP_CWORD=1 + {func} scope se scope + printf '%s\n' "${{COMPREPLY[@]}}" + "#, + script = staged.script.display() + ); + let out = Command::new(bash) + .args(["--noprofile", "--norc", "-c", &driver]) + .env("PATH", &staged.path) + .output() + .expect("run bash"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "serial", + "bash COMPREPLY wrong (stderr: {})", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// fish has a first-class batch query for exactly this, and picks the script up +/// from `~/.config/fish/completions` with no config edit at all. +#[cfg(not(windows))] +#[test] +fn fish_completes_se_to_serial() { + let Some(fish) = which("fish") else { + return eprintln!("skip: no fish"); + }; + let staged = stage("fish", "scope.fish"); + let out = Command::new(fish) + .arg("--no-config") + .arg("-c") + .arg(format!( + "source {}; complete -C 'scope se'", + staged.script.display() + )) + .env("PATH", &staged.path) + .env("HOME", staged.dir.path()) + .current_dir(staged.dir.path()) + .output() + .expect("run fish"); + let stdout = String::from_utf8_lossy(&out.stdout); + let got: Vec<&str> = stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.split('\t').next().unwrap()) + .collect(); + assert_eq!( + got, + vec!["serial"], + "fish completions wrong (stderr: {})", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// PowerShell exposes its completion engine directly, so no terminal is needed: +/// `TabExpansion2` returns exactly what pressing Tab would offer. This is the +/// only automated check of the Windows half of issue #231 — it runs on the +/// `windows-latest` CI job (and anywhere `pwsh` is installed). +#[test] +fn powershell_completes_se_to_serial() { + let Some(pwsh) = which("pwsh").or_else(|| which("powershell")) else { + return eprintln!("skip: no pwsh"); + }; + // Dot-sourcing needs a `.ps1` extension; PowerShell silently declines to run + // any other suffix and completion then falls back to file names. + let staged = stage("powershell", "_scope.ps1"); + let got = pwsh_complete(&pwsh, &staged, "scope se"); + assert_eq!(got, vec!["serial".to_string()], "pwsh completions wrong"); +} + +/// `scope ` with no partial word must list the subcommands. This is the +/// case a broken shell hook fails first (the word the shell hands over is +/// empty), and no amount of inspecting the script text catches it. +#[test] +fn powershell_bare_scope_lists_subcommands() { + let Some(pwsh) = which("pwsh").or_else(|| which("powershell")) else { + return eprintln!("skip: no pwsh"); + }; + let staged = stage("powershell", "_scope.ps1"); + let got = pwsh_complete(&pwsh, &staged, "scope "); + for want in ["serial", "rtt", "list", "completions", "--headless"] { + assert!( + got.contains(&want.to_string()), + "pwsh `scope ` should offer {want}, got {got:?}" + ); + } +} + +/// What PowerShell would offer for `line`, with the staged script dot-sourced. +fn pwsh_complete(pwsh: &Path, staged: &Staged, line: &str) -> Vec { + let driver = format!( + r#". "{script}" + $l = '{line}' + (TabExpansion2 $l $l.Length).CompletionMatches | + ForEach-Object {{ $_.CompletionText }}"#, + script = staged.script.display(), + ); + let out = Command::new(pwsh) + .args(["-NoProfile", "-NonInteractive", "-Command", &driver]) + .env("PATH", &staged.path) + .output() + .expect("run pwsh"); + assert!( + out.status.success(), + "pwsh failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + +/// zsh's completion system only runs inside a real line editor, so this is the +/// one shell that needs a PTY: spawn an interactive zsh with the script on +/// `$fpath`, type `scope se`, press Tab and read the redrawn line back. +/// +/// Note the install shape being pinned here — the script must be named `_scope` +/// and `fpath` must be extended *before* `compinit`. Both orderings fail +/// silently, which is what makes this the likeliest support ticket. +#[cfg(unix)] +#[test] +fn zsh_completes_se_to_serial() { + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + use std::io::{Read, Write}; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + let Some(zsh) = which("zsh") else { + return eprintln!("skip: no zsh"); + }; + let staged = stage("zsh", "zfunc/_scope"); + let rc = staged.dir.path().join("rc.zsh"); + std::fs::write( + &rc, + format!( + "fpath=({zfunc} $fpath)\n\ + autoload -Uz compinit\n\ + compinit -u -d {dump}\n\ + zstyle ':completion:*' menu no\n\ + setopt no_beep\n\ + RPROMPT=''\n\ + PROMPT='ZREADY '\n", + zfunc = staged.script.parent().unwrap().display(), + dump = staged.dir.path().join("zcompdump").display(), + ), + ) + .unwrap(); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + let mut cmd = CommandBuilder::new(zsh); + cmd.args(["-f", "-i"]); // -f: ignore the machine's rc files; we supply our own + cmd.env("TERM", "xterm-256color"); + cmd.env("PATH", &staged.path); + cmd.env("HOME", staged.dir.path()); + cmd.cwd(staged.dir.path()); + let mut child = pair.slave.spawn_command(cmd).expect("spawn zsh"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut writer = pair.master.take_writer().unwrap(); + let parser = Arc::new(Mutex::new(vt100::Parser::new(24, 100, 0))); + { + let parser = parser.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + while let Ok(n) = reader.read(&mut buf) { + if n == 0 { + break; + } + parser.lock().unwrap().process(&buf[..n]); + } + }); + } + let screen = || parser.lock().unwrap().screen().contents(); + let wait = |needle: &str, secs: u64| { + let start = Instant::now(); + loop { + let s = screen(); + if s.contains(needle) { + return s; + } + assert!( + start.elapsed() < Duration::from_secs(secs), + "timed out waiting for {needle:?}\n--- screen ---\n{s}\n---" + ); + std::thread::sleep(Duration::from_millis(60)); + } + }; + let mut send = |s: &str| { + writer.write_all(s.as_bytes()).unwrap(); + writer.flush().unwrap(); + }; + + send(&format!("source {}\n", rc.display())); + wait("ZREADY", 30); + send("scope se"); + wait("scope se", 15); + send("\t"); + let screen = wait("scope serial", 30); + + let _ = child.kill(); + let _ = child.wait(); + assert!(screen.contains("scope serial"), "screen:\n{screen}"); +}