From c1aa5dc07b6df1b0893edfb4568729c3ff210b34 Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Wed, 29 Jul 2026 15:41:38 -0300 Subject: [PATCH 1/2] feat: native shell completions for the CLI (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #231. `scope completions ` prints a completion script to stdout for bash, zsh, fish, powershell or elvish, generated ahead of time by clap_complete, so `scope se` + Tab completes to `scope serial` through the shell's own completion engine. Verified by hand on every shell the issue asks for: bash 3.2.57 (the macOS system bash) and 5.3.9, zsh 5.9 (real Tab in a PTY, installed exactly as the README documents), fish 4.8.1 and PowerShell 7.5.4. Two constraints are not obvious and both fail silently, so both are pinned by tests. First, `Cli` needs an explicit `#[command(name = "scope")]`: clap_derive otherwise names the command after CARGO_PKG_NAME (`scope-monitor`), and the script registers a completion for a command nobody runs. Second, the arm is dispatched right after `Cli::parse()`, before the fallible-setup closure, and returns early — a completion script is evaluated on every shell start-up, so it must not read `config.toml` (one typo there would break the user's prompt rather than just scope) and must not reach the `See you later ^^` epilogue, which the shell would try to execute. With the arm inside the closure, three of the new tests fail, the zsh one included. Static generation on purpose, not the `unstable-dynamic` API: that feature is semver-exempt, its bash and fish hooks drop the filename fallback that `--tag-file` needs, and live port values would come from the USB-only `list::usb_ports` with no fallback. Completing live serial ports is a follow-up. The subcommands also gained the doc comments clap needs to describe them in `--help` and in the scripts, and the root command an `after_help` tip pointing at `scope completions --help` — the only hint installer users ever see, since no install path sets completions up for them. tests/completions.rs has two layers: portable assertions on the emitted script (runs on all three CI OSes, and is what guards the two constraints above) and real completion driven through bash, fish, zsh and PowerShell, each skipped when its shell is absent so no runner fails for want of a shell. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/test-tui/SKILL.md | 2 +- CLAUDE.md | 4 + Cargo.lock | 26 +- Cargo.toml | 12 +- README.md | 43 +++ src/main.rs | 57 +++- tests/completions.rs | 456 +++++++++++++++++++++++++++++++ 7 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 tests/completions.rs 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..0d2da00 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. +- **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..e09f76f 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,7 +36,12 @@ 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, @@ -58,22 +64,42 @@ struct Cli { #[derive(Subcommand)] pub enum Commands { + /// Open a serial port. Without arguments, pick one interactively. Serial { port: Option, baudrate: Option, }, + /// List the available serial ports. List { #[clap(short, long)] verbose: bool, }, - Ble { - name_device: String, - mtu: u32, - }, + /// Connect to a BLE device (not yet implemented). + Ble { name_device: String, mtu: u32 }, + /// Attach to an RTT target via probe-rs. Without arguments, pick one + /// interactively. Rtt { target: Option, 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 +439,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 +509,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}"); +} From 2a75102657863917fe0fd7eca595699607b5cb62 Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Wed, 29 Jul 2026 17:40:00 -0300 Subject: [PATCH 2/2] docs: describe every CLI flag and positional in the help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static completion scripts surface argument descriptions (zsh and fish show them next to each candidate), which exposed that `-c/--capacity`, `-t/--tag-file` and `-l/--latency` never had doc comments — they completed and helped with an empty description. Neither did any positional: `serial `, `rtt `, `ble ` and `list --verbose`. Describe all of them, including where each value actually comes from: capacity and tag_file fall back to config.toml before their built-in default, tag_file is used verbatim (no `~`/`$VAR` expansion), latency is in microseconds and 0 yields instead of sleeping, and the RTT channel defaults to 0. Also fix CLAUDE.md, which documented --latency in milliseconds. Every polling loop treats it as microseconds (`plugin/engine.rs`, `graphics/headless.rs`, `graphics/graphics_task.rs`, `interfaces/serial_if.rs`) except `interfaces/rtt_if.rs::wait`, which uses `from_millis` — so with the default the RTT loop sleeps 1000x longer than every other. The README already said microseconds; the divergence in rtt_if is left alone here, since changing an interface's polling rate is not a documentation change. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- src/main.rs | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0d2da00..8448142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ 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. diff --git a/src/main.rs b/src/main.rs index e09f76f..1cd7646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,10 +45,17 @@ 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. @@ -66,20 +73,32 @@ struct Cli { 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 { name_device: String, mtu: u32 }, + 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.