From 274192a2b40dbc124416b84e07f7d26cb24990cb Mon Sep 17 00:00:00 2001 From: brianp Date: Sun, 9 Nov 2025 14:03:27 +0100 Subject: [PATCH 1/3] feature: autocompletion for bashrc, zsh, and fish --- Cargo.lock | 8 ++++ Cargo.toml | 18 +++++---- autocomplete/Cargo.toml | 13 ++++++ autocomplete/src/bash.rs | 84 +++++++++++++++++++++++++++++++++++++++ autocomplete/src/error.rs | 54 +++++++++++++++++++++++++ autocomplete/src/fish.rs | 68 +++++++++++++++++++++++++++++++ autocomplete/src/lib.rs | 31 +++++++++++++++ autocomplete/src/zsh.rs | 70 ++++++++++++++++++++++++++++++++ common/src/args.rs | 2 + src/main.rs | 8 +++- 10 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 autocomplete/Cargo.toml create mode 100644 autocomplete/src/bash.rs create mode 100644 autocomplete/src/error.rs create mode 100644 autocomplete/src/fish.rs create mode 100644 autocomplete/src/lib.rs create mode 100644 autocomplete/src/zsh.rs diff --git a/Cargo.lock b/Cargo.lock index 379fba4..f56ce74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,13 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "autocomplete" +version = "0.8.2" +dependencies = [ + "common", +] + [[package]] name = "base64" version = "0.22.1" @@ -248,6 +255,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" name = "muxed" version = "0.8.2" dependencies = [ + "autocomplete", "common", "docopt", "edit", diff --git a/Cargo.toml b/Cargo.toml index daa9030..448ab84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,19 +10,21 @@ test = false [workspace] members = [ + "autocomplete", "common", "edit", "load", "new", "retry_test", - "snapshot" + "snapshot", ] [dependencies] -common = { path = "./common" } -docopt = "1.1.0" -edit = { path = "./edit" } -load = { path = "./load" } -new = { path = "./new" } -snapshot = { path = "./snapshot" } -list = { path = "./list" } \ No newline at end of file +autocomplete = { path = "./autocomplete" } +common = { path = "./common" } +docopt = "1.1.0" +edit = { path = "./edit" } +load = { path = "./load" } +new = { path = "./new" } +snapshot = { path = "./snapshot" } +list = { path = "./list" } \ No newline at end of file diff --git a/autocomplete/Cargo.toml b/autocomplete/Cargo.toml new file mode 100644 index 0000000..4a483d4 --- /dev/null +++ b/autocomplete/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "autocomplete" +version = "0.8.2" +authors = ["Brian Pearce"] +publish = false +edition = "2024" + +[lib] +doctest = false +test = false + +[dependencies] +common = { path = "../common" } diff --git a/autocomplete/src/bash.rs b/autocomplete/src/bash.rs new file mode 100644 index 0000000..4c42e4e --- /dev/null +++ b/autocomplete/src/bash.rs @@ -0,0 +1,84 @@ +use std::env::VarError; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::{env, fmt, fs, io}; + +const BASH_COMPLETION: &str = r#"_muxed() { + local cur prev opts projects + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + opts="list ls edit load new snapshot autocomplete" + projects="$(muxed list -1 2>/dev/null)" + + # First argument after 'muxed' — offer commands + project names + if [[ ${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${opts} ${projects}" -- "${cur}") ) + return 0 + fi + + # If the previous word is a command that expects a project name + case "${prev}" in + edit|load|snapshot) + COMPREPLY=( $(compgen -W "${projects}" -- "${cur}") ) + return 0 + ;; + esac +} +complete -F _muxed muxed +"#; + +#[derive(Debug)] +pub enum BashError { + Var(VarError), + Io(io::Error), +} + +impl fmt::Display for BashError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + BashError::Var(err) => write!(f, "{}", err), + BashError::Io(err) => write!(f, "{}", err), + } + } +} + +impl std::error::Error for BashError {} + +impl From for BashError { + fn from(err: io::Error) -> Self { + BashError::Io(err) + } +} + +impl From for BashError { + fn from(err: VarError) -> Self { + BashError::Var(err) + } +} + +pub fn install() -> Result<(), BashError> { + let home = env::var("HOME")?; + let completion_dir = PathBuf::from(&home).join(".bash_completion.d"); + fs::create_dir_all(&completion_dir)?; + + let completion_file = completion_dir.join("muxed"); + fs::write(&completion_file, BASH_COMPLETION)?; + + let bashrc = PathBuf::from(&home).join(".bashrc"); + let source_line = format!("source {}\n", completion_file.display()); + + if bashrc.exists() { + let content = fs::read_to_string(&bashrc)?; + if !content.contains(source_line.trim()) { + let mut file = OpenOptions::new().append(true).open(&bashrc)?; + writeln!(file, "\n# muxed completion")?; + write!(file, "{}", source_line)?; + } + } + + println!("✓ Bash completion installed. Run: source ~/.bashrc"); + Ok(()) +} diff --git a/autocomplete/src/error.rs b/autocomplete/src/error.rs new file mode 100644 index 0000000..69a623c --- /dev/null +++ b/autocomplete/src/error.rs @@ -0,0 +1,54 @@ +use crate::bash::BashError; +use crate::fish::FishError; +use crate::zsh::ZshError; +use std::env::VarError; +use std::fmt; + +#[derive(Debug)] +pub enum AutocompleteError { + ShellNotSupported, + Var(VarError), + Bash(BashError), + Zsh(ZshError), + Fish(FishError), +} + +impl fmt::Display for AutocompleteError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AutocompleteError::ShellNotSupported => { + write!(f, "Sorry, that shell is not supported at this time") + } + AutocompleteError::Var(err) => write!(f, "{}", err), + AutocompleteError::Bash(err) => write!(f, "Bash error: {}", err), + AutocompleteError::Zsh(err) => write!(f, "Zsh error: {}", err), + AutocompleteError::Fish(err) => write!(f, "Fish error: {}", err), + } + } +} + +impl std::error::Error for AutocompleteError {} + +impl From for AutocompleteError { + fn from(err: VarError) -> Self { + AutocompleteError::Var(err) + } +} + +impl From for AutocompleteError { + fn from(err: BashError) -> Self { + AutocompleteError::Bash(err) + } +} + +impl From for AutocompleteError { + fn from(err: ZshError) -> Self { + AutocompleteError::Zsh(err) + } +} + +impl From for AutocompleteError { + fn from(err: FishError) -> Self { + AutocompleteError::Fish(err) + } +} diff --git a/autocomplete/src/fish.rs b/autocomplete/src/fish.rs new file mode 100644 index 0000000..f622566 --- /dev/null +++ b/autocomplete/src/fish.rs @@ -0,0 +1,68 @@ +use std::env::VarError; +use std::path::PathBuf; +use std::{env, fmt, fs, io}; + +const FISH_COMPLETION: &str = r#"function __fish_muxed_projects + muxed list -1 2>/dev/null +end + +function __fish_muxed_needs_command + set cmd (commandline -opc) + test (count $cmd) -eq 1 +end + +function __fish_muxed_needs_project + set cmd (commandline -opc) + set sub (string split ' ' -- $cmd)[2] + contains -- $sub edit load snapshot +end + +# Subcommands +complete -c muxed -n '__fish_muxed_needs_command' -a "list ls edit load new snapshot autocomplete" + +# Project completions for commands that expect a project +complete -c muxed -n '__fish_muxed_needs_project' -a '(__fish_muxed_projects)' +"#; + +#[derive(Debug)] +pub enum FishError { + Var(VarError), + Io(io::Error), +} + +impl fmt::Display for FishError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + FishError::Var(err) => write!(f, "{}", err), + FishError::Io(err) => write!(f, "{}", err), + } + } +} + +impl std::error::Error for FishError {} + +impl From for FishError { + fn from(err: io::Error) -> Self { + FishError::Io(err) + } +} + +impl From for FishError { + fn from(err: VarError) -> Self { + FishError::Var(err) + } +} + +pub fn install() -> Result<(), FishError> { + let home = env::var("HOME")?; + let completion_dir = PathBuf::from(&home) + .join(".config") + .join("fish") + .join("completions"); + fs::create_dir_all(&completion_dir)?; + + fs::write(completion_dir.join("muxed.fish"), FISH_COMPLETION)?; + + println!("✓ Fish completion installed. Start new session to use."); + Ok(()) +} diff --git a/autocomplete/src/lib.rs b/autocomplete/src/lib.rs new file mode 100644 index 0000000..41ab4ed --- /dev/null +++ b/autocomplete/src/lib.rs @@ -0,0 +1,31 @@ +use common::args::Args; +use std::env; + +mod bash; +mod error; +mod fish; +mod zsh; + +use crate::error::AutocompleteError; + +type Result = std::result::Result; + +pub fn autocomplete(_args: Args) -> Result<()> { + let shell = detect_shell()?; + println!("Detected shell: {}", shell); + + match shell.as_str() { + "bash" => bash::install()?, + "zsh" => zsh::install()?, + "fish" => fish::install()?, + _ => return Err(AutocompleteError::ShellNotSupported), + } + + Ok(()) +} + +fn detect_shell() -> Result { + let shell_path = env::var("SHELL")?; + let shell_name = shell_path.split('/').next_back().unwrap_or("unknown"); + Ok(shell_name.to_string()) +} diff --git a/autocomplete/src/zsh.rs b/autocomplete/src/zsh.rs new file mode 100644 index 0000000..7676e7d --- /dev/null +++ b/autocomplete/src/zsh.rs @@ -0,0 +1,70 @@ +use std::env::VarError; +use std::path::PathBuf; +use std::{env, fmt, fs, io}; + +const ZSH_COMPLETION: &str = r#"#compdef muxed + +_muxed() { + local projectdir=~/.muxed + local -a commands + local -a projects + commands=(list ls edit load new snapshot autocomplete) + + # Guaranteed stripping via for-loop just to be safe with all shells + projects=("${(@f)$(muxed list -1 2>/dev/null)}") + + if (( CURRENT == 2 )); then + # Merge commands and projects for the first positional argument + compadd -- $commands $projects + elif (( CURRENT == 3 )); then + # When completing after a command that expects a project name + if [[ "$words[2]" == (edit|load|snapshot) ]]; then + compadd -- $projects + return + fi + fi +} + +compdef _muxed muxed +"#; + +#[derive(Debug)] +pub enum ZshError { + Var(VarError), + Io(io::Error), +} + +impl fmt::Display for ZshError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ZshError::Var(err) => write!(f, "{}", err), + ZshError::Io(err) => write!(f, "{}", err), + } + } +} + +impl std::error::Error for ZshError {} + +impl From for ZshError { + fn from(err: io::Error) -> Self { + ZshError::Io(err) + } +} + +impl From for ZshError { + fn from(err: VarError) -> Self { + ZshError::Var(err) + } +} + +pub fn install() -> Result<(), ZshError> { + let home = env::var("HOME")?; + let completion_dir = PathBuf::from(&home).join(".zsh").join("completions"); + fs::create_dir_all(&completion_dir)?; + + fs::write(completion_dir.join("_muxed"), ZSH_COMPLETION)?; + + println!("✓ Zsh completion installed. Run: exec zsh"); + + Ok(()) +} diff --git a/common/src/args.rs b/common/src/args.rs index fb0280f..ce46b94 100644 --- a/common/src/args.rs +++ b/common/src/args.rs @@ -35,6 +35,7 @@ pub struct Args { pub cmd_snapshot: bool, pub cmd_list: bool, pub cmd_ls: bool, + pub cmd_autocomplete: bool, } impl Default for Args { @@ -49,6 +50,7 @@ impl Default for Args { cmd_snapshot: false, cmd_list: false, cmd_ls: false, + cmd_autocomplete: false, flag_d: true, flag_debug: false, flag_f: false, diff --git a/src/main.rs b/src/main.rs index 09668b1..cb10621 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ //! Muxed. A tmux project manager with no runtime dependencies. +extern crate autocomplete; extern crate common; extern crate docopt; extern crate edit; @@ -26,10 +27,12 @@ macro_rules! try_or_err ( }) ); -static DISALLOWED_SHORTHAND_PROJECT_NAMES: [&str; 4] = ["new", "edit", "load", "snapshot"]; +static DISALLOWED_SHORTHAND_PROJECT_NAMES: [&str; 5] = + ["autocomplete", "new", "edit", "load", "snapshot"]; static USAGE: &str = " Usage: + muxed autocomplete muxed (list | ls) [-1] muxed [flags] [options] muxed edit [options] @@ -55,6 +58,7 @@ Args: The name of your project to open Subcommands: + autocomplete Create autocompletions for bash, fish, or zsh list List the available project configs edit Edit an existing project file load Load the specified project, this is the default command @@ -106,6 +110,8 @@ pub fn main() { try_or_err!(new::new(args)); } else if args.cmd_snapshot { try_or_err!(snapshot::snapshot(args)); + } else if args.cmd_autocomplete { + try_or_err!(autocomplete::autocomplete(args)) } else if args.cmd_list || args.cmd_ls { try_or_err!(list::list(args)); } else { From 0edcf352b0a3dea271b553314522c7a6c857b356 Mon Sep 17 00:00:00 2001 From: brianp Date: Sun, 9 Nov 2025 14:46:47 +0100 Subject: [PATCH 2/3] add docs for autocomplete --- autocomplete/src/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/autocomplete/src/lib.rs b/autocomplete/src/lib.rs index 41ab4ed..c6166fd 100644 --- a/autocomplete/src/lib.rs +++ b/autocomplete/src/lib.rs @@ -10,6 +10,19 @@ use crate::error::AutocompleteError; type Result = std::result::Result; +/// Detects the user's current shell and installs shell autocompletion support. +/// +/// Supports installing autocomplete for "bash", "zsh", and "fish" shells. +/// Returns an error if the shell is not supported, or if installation fails for any reason. +/// +/// Caveat: It doesn't detect the running shell right now. It detects the +/// configured user login shell. +/// +/// # Arguments +/// * `_args` - Command-line arguments relevant to the autocomplete process. +/// +/// # Returns +/// * `Result<()>` - Ok on successful installation, or `AutocompleteError` if an error occurs. pub fn autocomplete(_args: Args) -> Result<()> { let shell = detect_shell()?; println!("Detected shell: {}", shell); @@ -21,9 +34,22 @@ pub fn autocomplete(_args: Args) -> Result<()> { _ => return Err(AutocompleteError::ShellNotSupported), } + Ok(()) } +/// Detects the user's configured login shell by inspecting the SHELL environment variable. +/// +/// This function does NOT detect the currently running shell process. Instead, it retrieves the shell path +/// specified by the SHELL environment variable, which typically points to the user's default login shell as set +/// in their system configuration (e.g., `/bin/zsh`, `/bin/bash`). The shell name is extracted from the end of this path. +/// +/// # Returns +/// * `Result` - The name of the configured login shell on success, or an `AutocompleteError` if the SHELL variable +/// is missing or invalid. +/// +/// # Example +/// If SHELL is `/bin/zsh`, this will return `"zsh"`.fn detect_shell() -> Result { fn detect_shell() -> Result { let shell_path = env::var("SHELL")?; let shell_name = shell_path.split('/').next_back().unwrap_or("unknown"); From b2b1c92fc583feead113976bea8a89a4ebf41c0b Mon Sep 17 00:00:00 2001 From: brianp Date: Sun, 9 Nov 2025 14:49:09 +0100 Subject: [PATCH 3/3] fix: fmt --- autocomplete/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/autocomplete/src/lib.rs b/autocomplete/src/lib.rs index c6166fd..5620c59 100644 --- a/autocomplete/src/lib.rs +++ b/autocomplete/src/lib.rs @@ -34,7 +34,6 @@ pub fn autocomplete(_args: Args) -> Result<()> { _ => return Err(AutocompleteError::ShellNotSupported), } - Ok(()) }