Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions crates/minimal/src/completions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//! `min completions` — the shell integration, printed or installed.
//!
//! Two verbs over one artifact, the registration shim:
//!
//! - [`cmd_print`] writes it to stdout, for `source <(min completions print
//! bash)` and for anyone piping it somewhere of their own choosing.
//! - [`cmd_install`] writes it to the file each shell already looks in, with
//! the bookkeeping that used to live only in `scripts/install.sh` (R9.3):
//! per-shell target path, atomic write, unwritable-dir tolerance, zsh
//! `zcompdump` invalidation. That logic was unreachable for anyone who did
//! not install through `curl | sh`; now the installer calls this instead of
//! carrying its own copy, and reads the paths it prints on stdout — one per
//! line — into its install record.
//!
//! Distinct from `completion.rs`, which is the runtime completion engine the
//! installed shim calls back into.

use std::io::Write as _;
use std::path::{Path, PathBuf};

use anyhow::Context as _;
use clap_complete::Shell;

/// The shells [`cmd_install`] knows a destination for.
///
/// Narrower than what [`cmd_print`] accepts: installing needs a file the shell
/// autoloads on its own, and these are the three with a standard, user-level
/// one — the same three `scripts/install.sh` has always written.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum InstallShell {
Bash,
Zsh,
Fish,
}

impl InstallShell {
/// Every installable shell, in the order a bare `install` walks them.
const ALL: [Self; 3] = [Self::Bash, Self::Zsh, Self::Fish];

/// The name clap_complete's shell registry knows this shell by.
fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
}
}

/// The file this shell autoloads `min`'s registration from.
///
/// The three paths `scripts/install.sh` computes (R9.3), resolved by the
/// same rules: `${XDG_DATA_HOME:-$HOME/.local/share}` and
/// `${XDG_CONFIG_HOME:-$HOME/.config}`. Deliberately not
/// [`dirs::data_dir`] — on macOS that is `~/Library/Application Support`,
/// where no shell looks.
fn target(self) -> Result<PathBuf, anyhow::Error> {
Ok(match self {
Self::Bash => {
xdg_home("XDG_DATA_HOME", ".local/share")?.join("bash-completion/completions/min")
}
// `_min`, not `min`: zsh autoloads a function file named for the
// command it completes, underscore-prefixed.
Self::Zsh => xdg_home("XDG_DATA_HOME", ".local/share")?.join("zsh/completions/_min"),
Self::Fish => xdg_home("XDG_CONFIG_HOME", ".config")?.join("fish/completions/min.fish"),
})
}
}

/// Print the shell integration for `shell` on stdout.
///
/// This is a *registration* shim, not a completion table: a dozen lines that
/// teach the shell to ask `min` itself what to complete, rather than the
/// thousand-line static script this used to emit. That indirection is the
/// whole point — session names and IDs only exist at runtime, so a table
/// baked at install time cannot contain them (see `completion.rs`).
///
/// The shim calls `min` by bare name so it resolves through `PATH` — an
/// upgrade that moves the binary keeps working, and the installer puts its
/// bindir on `PATH` in the same breath as writing this file.
pub fn cmd_print(shell: Shell) -> Result<(), anyhow::Error> {
let shim = render(&shell.to_string())?;
std::io::stdout()
.write_all(&shim)
.context("write shell integration to stdout")
}

/// Install the shell integration for `shells` (all of [`InstallShell::ALL`]
/// when empty), printing each path actually written on stdout, one per line.
///
/// Those paths are the contract with `scripts/install.sh`: it records what we
/// print so `--uninstall` can undo it. Nothing else goes to stdout.
///
/// Best-effort per shell, exactly as the installer was: a shell that could not
/// be installed for is a warning on stderr, not a failure. When this runs at
/// the end of an install the binaries are already correctly in place, and one
/// unwritable shared directory must not make the run look failed.
pub fn cmd_install(shells: &[InstallShell]) -> Result<(), anyhow::Error> {
let targets = if shells.is_empty() {
&InstallShell::ALL[..]
} else {
shells
};

let mut stdout = std::io::stdout().lock();
for shell in targets {
match install_one(*shell) {
Ok(path) => writeln!(stdout, "{}", path.display())
.context("write installed completion path to stdout")?,
Err(err) => eprintln!("warning: {err:#}"),
}
}
Ok(())
}

/// Install one shell's registration, returning the path written.
fn install_one(shell: InstallShell) -> Result<PathBuf, anyhow::Error> {
let target = shell
.target()
.with_context(|| format!("failed to install {} completions", shell.name()))?;
let dir = target
.parent()
.with_context(|| format!("{} has no parent directory", target.display()))?;
let tmp = tmp_sibling(&target);

// Probe that the directory is writable BEFORE generating anything: these
// are shared, user-owned locations that can pre-exist unwritable (a
// root-owned `~/.config/fish/completions`, say), and there is no point
// rendering a shim we cannot place. The probe file is the temp sibling the
// atomic write needs anyway.
std::fs::create_dir_all(dir)
.and_then(|()| std::fs::File::create(&tmp))
// The io error itself is deliberately dropped: "Permission denied" on a
// path the user never named is noise next to the directory that needs
// fixing, and the installer has always reported this case that way.
.map_err(|_| {
anyhow::anyhow!(
"failed to install {} completions ({} is not writable)",
shell.name(),
dir.display()
)
})?;

let shim = render(shell.name())?;
// Written to the sibling and renamed over the destination, so a completing
// shell never sources a half-written file.
if let Err(err) = std::fs::write(&tmp, &shim).and_then(|()| std::fs::rename(&tmp, &target)) {
let _ = std::fs::remove_file(&tmp);
return Err(anyhow::anyhow!(
"failed to install {} completions ({}: {err})",
shell.name(),
target.display()
));
}

if shell == InstallShell::Zsh {
drop_zcompdump();
}
Ok(target)
}

/// Render the registration shim for `shell` (a clap_complete shell name).
fn render(shell: &str) -> Result<Vec<u8>, anyhow::Error> {
let shells = clap_complete::env::Shells::builtins();
let completer = shells
.completer(shell)
.with_context(|| format!("no shell integration for `{shell}`"))?;

let mut out = Vec::new();
completer
.write_registration(crate::COMPLETE_VAR, "min", "min", "min", &mut out)
.context("render shell integration")?;
Ok(out)
}

/// `<target>.tmp.<pid>` — the installer's temp-file name, kept identical so a
/// leftover from either writer looks the same on disk.
fn tmp_sibling(target: &Path) -> PathBuf {
let mut tmp = target.as_os_str().to_owned();
tmp.push(format!(".tmp.{}", std::process::id()));
PathBuf::from(tmp)
}

/// Drop a pre-existing `compinit` dump after (re)writing zsh's completion file.
///
/// compinit's staleness check is only (zsh version, completion-file count),
/// which cannot see `_min` change when the count happens to stay equal — so a
/// dump can keep trusting its stale contents, and "restart your shell" does not
/// fix it. The dump is a pure, regenerable cache, so dropping it just forces a
/// real rescan on the next zsh startup. Best-effort: a dump we cannot remove is
/// not a failed install.
fn drop_zcompdump() {
let Some(dump) = zcompdump_path() else { return };
if dump.exists() && std::fs::remove_file(&dump).is_ok() {
eprintln!("cleared compinit dump cache {}", dump.display());
}
}

/// `${ZDOTDIR:-$HOME}/.zcompdump`, the path zsh's `compinit` caches to.
fn zcompdump_path() -> Option<PathBuf> {
let base = env_non_empty("ZDOTDIR").or_else(|| env_non_empty("HOME"))?;
Some(PathBuf::from(base).join(".zcompdump"))
}

/// `$<var>` when set and non-empty, else `$HOME/<fallback>` — the `${VAR:-…}`
/// resolution `scripts/install.sh` uses for the completion dirs.
fn xdg_home(var: &str, fallback: &str) -> Result<PathBuf, anyhow::Error> {
if let Some(value) = env_non_empty(var) {
return Ok(PathBuf::from(value));
}
let home = env_non_empty("HOME").with_context(|| format!("neither ${var} nor $HOME is set"))?;
Ok(PathBuf::from(home).join(fallback))
}

/// The value of `var` when it is set to something non-empty. An empty variable
/// counts as unset, matching the shell's `${VAR:-default}`.
fn env_non_empty(var: &str) -> Option<std::ffi::OsString> {
std::env::var_os(var).filter(|value| !value.is_empty())
}
77 changes: 37 additions & 40 deletions crates/minimal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod attach;
pub mod autospawn;
pub mod client;
pub mod completion;
pub mod completions;
pub mod config;
pub mod diag;
pub mod dirs;
Expand Down Expand Up @@ -137,9 +138,10 @@ pub enum Command {
/// Never starts a daemon and never fails: no daemon means no output.
#[command(name = completion::COMPLETE_SESSION_STR, hide = true)]
CompleteSessionStr(CompleteSessionStrArgs),
/// Generate shell completion script
/// Print or install the shell tab-completion integration
#[command(
long_about = "Generate a shell tab-completion script for the min CLI.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(min completions bash)"
visible_alias = "completion",
long_about = "Print or install the shell tab-completion integration for the min CLI.\n\n source <(min completions print bash)\n min completions install"
)]
Completions(CompletionsArgs),
}
Expand Down Expand Up @@ -557,11 +559,37 @@ pub struct CompleteSessionStrArgs {

#[derive(Debug, clap::Args)]
pub struct CompletionsArgs {
/// The shell type for a CLI completion script should be printed
#[command(subcommand)]
pub command: CompletionsCommand,
}

#[derive(Debug, Subcommand)]
pub enum CompletionsCommand {
/// Print the shell integration on stdout
#[command(
long_about = "Print the shell integration for a shell on stdout.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(min completions print bash)"
)]
Print(CompletionsPrintArgs),
/// Write the shell integration into each shell's completion directory
#[command(
long_about = "Write the shell integration into each shell's user-level completion directory,\nand print every path written on stdout, one per line.\n\n bash ${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/min\n zsh ${XDG_DATA_HOME:-~/.local/share}/zsh/completions/_min\n fish ${XDG_CONFIG_HOME:-~/.config}/fish/completions/min.fish\n\nA shell that cannot be installed for (an unwritable shared directory, say) is\na warning on stderr, not a failure."
)]
Install(CompletionsInstallArgs),
}

#[derive(Debug, Args)]
pub struct CompletionsPrintArgs {
/// The shell to print the integration for
#[arg(value_parser)]
pub shell: Shell,
}

#[derive(Debug, Args)]
pub struct CompletionsInstallArgs {
/// Shells to install for (default: every supported shell)
pub shells: Vec<completions::InstallShell>,
}

/// The process-wide trace context, minted once at command dispatch. The
/// root span carries its ids into every log line, and the SSH client sends
/// the same context to the daemon as a `TRACEPARENT` channel env request —
Expand Down Expand Up @@ -629,7 +657,10 @@ async fn run_command(cli: Cli) -> Result<(), anyhow::Error> {
Some(Command::CompleteSessionStr(args)) => {
completion::cmd_complete_session_str(&cli.global_args, args).await
}
Some(Command::Completions(CompletionsArgs { shell })) => cmd_completions(shell),
Some(Command::Completions(CompletionsArgs { command })) => match command {
CompletionsCommand::Print(args) => completions::cmd_print(args.shell),
CompletionsCommand::Install(args) => completions::cmd_install(&args.shells),
},
}
}

Expand Down Expand Up @@ -821,45 +852,11 @@ fn confirm(question: &str, default: bool) -> Result<bool, anyhow::Error> {
})
}

/// Print the shell integration for `shell` on stdout.
///
/// This is a *registration* shim, not a completion table: a dozen lines that
/// teach the shell to ask `min` itself what to complete, rather than the
/// thousand-line static script this used to emit. That indirection is the
/// whole point — session names and IDs only exist at runtime, so a table
/// baked at install time cannot contain them (see `completion.rs`).
///
/// The installed file paths are unchanged, and so is
/// `scripts/install.sh` (R9.3): zsh's shim still opens with `#compdef min`,
/// so it autoloads from an fpath dir as `_min`, and bash's and fish's are
/// still plain source-able files.
///
/// The shim calls `min` by bare name so it resolves through `PATH` — an
/// upgrade that moves the binary keeps working, and the installer puts its
/// bindir on `PATH` in the same breath as writing this file.
fn cmd_completions(shell: Shell) -> Result<(), anyhow::Error> {
let name = shell.to_string();
let shells = clap_complete::env::Shells::builtins();
let completer = shells
.completer(&name)
.with_context(|| format!("no shell integration for `{name}`"))?;

let mut out = Vec::new();
completer
.write_registration(COMPLETE_VAR, "min", "min", "min", &mut out)
.context("render shell integration")?;

std::io::stdout()
.write_all(&out)
.context("write shell integration to stdout")?;
Ok(())
}

/// The environment variable a shell sets to ask `min` for completions.
///
/// clap_complete's default, named here because both ends have to agree: the
/// shim emitted by [`cmd_completions`] sets it, and the `CompleteEnv` call in
/// `main.rs` reads it.
/// shim rendered by [`completions::cmd_print`] sets it, and the `CompleteEnv`
/// call in `main.rs` reads it.
pub const COMPLETE_VAR: &str = "COMPLETE";

/// List sessions via the `ListSessions` RPC.
Expand Down
8 changes: 7 additions & 1 deletion crates/minimal/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ async fn run() -> ExitCode {
let cli = minimal::Cli::parse();

let registry = tracing_subscriber::registry().with(filter);
if matches!(cli.command, Some(minimal::Command::CompleteSessionStr(_))) {
// `completions` joins the completion handler on stderr: its stdout is a
// contract — the shim to `source`, or the installed paths the installer
// records — and a log line landing in it would be read as content.
if matches!(
cli.command,
Some(minimal::Command::CompleteSessionStr(_) | minimal::Command::Completions(_))
) {
registry
.with(fmt::layer().with_writer(std::io::stderr))
.init();
Expand Down
Loading