From 89e0439e215830c566f50283a86f3ad6bed156d5 Mon Sep 17 00:00:00 2001 From: Tuna Cuma Date: Sun, 4 Jan 2026 20:41:28 +0300 Subject: [PATCH] feat(zsh-vi-man): add plugin for smart man page lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds zsh-vi-man plugin that provides smart man page lookup for zsh vi mode and emacs mode. Press K in vi normal mode, Ctrl-X k in emacs mode, or Ctrl-K in vi insert mode on any command or option to open its man page. Features: - Smart subcommand detection (git commit → man git-commit) - Option jumping (grep -r → jumps to -r entry in man page) - Combined options support (rm -rf → finds both -r and -f) - Pipe support (cat file | grep -i → opens man grep) - Multiple pager support (less, vim, nvim) --- plugins/zsh-vi-man/README.md | 112 ++++++++++++++++++++ plugins/zsh-vi-man/lib/keybinding.zsh | 50 +++++++++ plugins/zsh-vi-man/lib/pager.zsh | 128 +++++++++++++++++++++++ plugins/zsh-vi-man/lib/parser.zsh | 49 +++++++++ plugins/zsh-vi-man/lib/pattern.zsh | 107 +++++++++++++++++++ plugins/zsh-vi-man/zsh-vi-man.plugin.zsh | 65 ++++++++++++ 6 files changed, 511 insertions(+) create mode 100644 plugins/zsh-vi-man/README.md create mode 100644 plugins/zsh-vi-man/lib/keybinding.zsh create mode 100644 plugins/zsh-vi-man/lib/pager.zsh create mode 100644 plugins/zsh-vi-man/lib/parser.zsh create mode 100644 plugins/zsh-vi-man/lib/pattern.zsh create mode 100644 plugins/zsh-vi-man/zsh-vi-man.plugin.zsh diff --git a/plugins/zsh-vi-man/README.md b/plugins/zsh-vi-man/README.md new file mode 100644 index 000000000..c43e452dc --- /dev/null +++ b/plugins/zsh-vi-man/README.md @@ -0,0 +1,112 @@ +# zsh-vi-man plugin + +Smart man page lookup for zsh vi mode (and emacs mode). + +Press `K` in vi normal mode, `Ctrl-X k` in emacs mode, or `Ctrl-K` in vi insert mode on any +command or option to instantly open its man page. If your cursor is on an option (like `-r` +or `--recursive`), it will jump directly to that option in the man page. + +To use it, add `zsh-vi-man` to the plugins array in your zshrc file: + +```zsh +plugins=(... zsh-vi-man) +``` + +## Features + +- **Smart Detection**: Automatically finds the right man page for subcommands + (`git commit` → `man git-commit`, `docker run` → `man docker-run`) +- **Option Jumping**: Opens man page directly at the option definition + (`grep -r` → jumps to `-r` entry) +- **Combined Options**: Works with combined short options (`rm -rf` → finds both `-r` and `-f`) +- **Value Extraction**: Handles options with values (`--color=always` → searches `--color`) +- **Pipe Support**: Detects correct command in pipelines (`cat file | grep -i` → opens `man grep`) +- **Multiple Formats**: Supports various man page styles (GNU, jq-style, find-style) + +## Usage + +### Vi Normal Mode (Default) + +1. Type a command (e.g., `ls -la` or `git commit --amend`) +2. Press `Escape` to enter vi normal mode +3. Move cursor to any word +4. Press `K` to open the man page + +### Emacs Mode / Vi Insert Mode + +Without leaving insert mode or if using emacs mode: + +- **Emacs mode**: Press `Ctrl-X` then `k` +- **Vi insert mode**: Press `Ctrl-K` + +### Examples + +| Command | Cursor On | Result | +| :--------------------- | :------------- | :----------------------------------- | +| `ls -la` | `ls` | Opens `man ls` | +| `ls -la` | `-la` | Opens `man ls`, jumps to `-l` | +| `git commit --amend` | `commit` | Opens `man git-commit` | +| `grep --color=auto` | `--color=auto` | Opens `man grep`, jumps to `--color` | +| `cat file \| sort -r` | `-r` | Opens `man sort`, jumps to `-r` | +| `find . -name "*.txt"` | `-name` | Opens `man find`, jumps to `-name` | + +## Configuration + +Set these variables **before** Oh My Zsh is sourced: + +```zsh +# Vi normal mode key (default: K) +ZVM_MAN_KEY='?' + +# Emacs mode key sequence (default: ^Xk, i.e., Ctrl-X k) +ZVM_MAN_KEY_EMACS='^X^K' # Example: Ctrl-X Ctrl-K + +# Vi insert mode key (default: ^K, i.e., Ctrl-K) +ZVM_MAN_KEY_INSERT='^H' # Example: Ctrl-H + +# Enable/disable emacs mode binding (default: true) +ZVM_MAN_ENABLE_EMACS=false + +# Enable/disable vi insert mode binding (default: true) +ZVM_MAN_ENABLE_INSERT=false + +# Use a different pager (default: less, supports nvim/vim) +ZVM_MAN_PAGER='nvim' +``` + +## Troubleshooting + +**Keybindings not working?** + +If keybindings don't work after sourcing the plugin, try running: + +```zsh +zvm_man_rebind +``` + +This can happen if: + +- Your plugin manager loads plugins before setting up keymaps +- You call `bindkey -e` or `bindkey -v` after the plugin loads +- Another plugin resets your keybindings + +For persistent issues, add this to your `.zshrc` **after** sourcing Oh My Zsh: + +```zsh +# Ensure zsh-vi-man bindings are set +zvm_man_rebind +``` + +## Integration with zsh-vi-mode + +This plugin works seamlessly with [zsh-vi-mode](https://github.com/jeffreytse/zsh-vi-mode). +It automatically detects zsh-vi-mode and hooks into its lazy keybindings system. + +## Author + +- [Tuna Cuma](https://github.com/TunaCuma) + +## License + +MIT License - see [LICENSE](https://github.com/TunaCuma/zsh-vi-man/blob/main/LICENSE) for details. + diff --git a/plugins/zsh-vi-man/lib/keybinding.zsh b/plugins/zsh-vi-man/lib/keybinding.zsh new file mode 100644 index 000000000..aefa9a776 --- /dev/null +++ b/plugins/zsh-vi-man/lib/keybinding.zsh @@ -0,0 +1,50 @@ +# lib/keybinding.zsh - Keybinding management for different shell modes +# Supports vi normal/insert modes and emacs mode + +# Internal function to bind keys in all configured modes +_zvm_man_bind_key() { + # Bind in vi normal mode (always enabled) + bindkey -M vicmd "${ZVM_MAN_KEY}" zvm-man 2>/dev/null + + # Handle emacs mode binding + if [[ "${ZVM_MAN_ENABLE_EMACS}" == true ]]; then + bindkey -M emacs "${ZVM_MAN_KEY_EMACS}" zvm-man 2>/dev/null + else + # Remove existing binding if disabled + bindkey -M emacs -r "${ZVM_MAN_KEY_EMACS}" 2>/dev/null + fi + + # Handle vi insert mode binding + if [[ "${ZVM_MAN_ENABLE_INSERT}" == true ]]; then + bindkey -M viins "${ZVM_MAN_KEY_INSERT}" zvm-man 2>/dev/null + else + # Remove existing binding if disabled + bindkey -M viins -r "${ZVM_MAN_KEY_INSERT}" 2>/dev/null + fi +} + +# Public function for users to manually rebind if needed +# Usage: zvm_man_rebind +zvm_man_rebind() { + _zvm_man_bind_key +} + +# Setup keybindings with zsh-vi-mode compatibility +# Handles both immediate binding and lazy loading scenarios +zvm_setup_keybindings() { + if (( ${+functions[zvm_after_lazy_keybindings]} )); then + # zsh-vi-mode is loaded with lazy keybindings, hook into it + if [[ -z "${ZVM_LAZY_KEYBINDINGS}" ]] || [[ "${ZVM_LAZY_KEYBINDINGS}" == true ]]; then + zvm_after_lazy_keybindings_commands+=(_zvm_man_bind_key) + else + _zvm_man_bind_key + fi + elif (( ${+functions[zvm_after_init]} )); then + # zsh-vi-mode without lazy keybindings + zvm_after_init_commands+=(_zvm_man_bind_key) + else + # Standalone or other vi-mode setups - bind immediately + _zvm_man_bind_key + fi +} + diff --git a/plugins/zsh-vi-man/lib/pager.zsh b/plugins/zsh-vi-man/lib/pager.zsh new file mode 100644 index 000000000..8fdf5e8ec --- /dev/null +++ b/plugins/zsh-vi-man/lib/pager.zsh @@ -0,0 +1,128 @@ +# lib/pager.zsh - Pager-specific man page opening logic +# Handles different pagers: less, nvim, vim, etc. + +# Detect the pager type from ZVM_MAN_PAGER +# Output: "nvim", "vim", "less", or "other" +zvm_detect_pager_type() { + local pager_basename="${ZVM_MAN_PAGER##*/}" + + if [[ "$pager_basename" =~ ^nvim ]]; then + echo "nvim" + elif [[ "$pager_basename" =~ ^vim$ ]]; then + echo "vim" + elif [[ "$pager_basename" =~ ^less$ ]]; then + echo "less" + else + echo "other" + fi +} + +# Open man page with neovim +# Uses neovim's built-in Man command with search +# Input: $1 = man_page, $2 = search_pattern (vim regex, optional) +# Returns: 0 on success, 1 on failure +zvm_open_nvim() { + local man_page="$1" + local search_term="$2" + + # Run nvim exactly as user would: nvim +"Man " +only +/ + # No stdin/stdout/stderr redirects - let nvim have full terminal access + # This prevents issues with plugins like image.nvim that need terminal size + # Set MANWIDTH to a large number to prevent line wrapping in man pages + if [[ -n "$search_term" ]]; then + MANWIDTH=999 ${ZVM_MAN_PAGER} +"Man ${man_page}" +only +"/${search_term}" || \ + MANWIDTH=999 ${ZVM_MAN_PAGER} +"Man ${man_page}" +only || \ + return 1 + else + MANWIDTH=999 ${ZVM_MAN_PAGER} +"Man ${man_page}" +only || \ + return 1 + fi + + return 0 +} + +# Open man page with vim +# Vim doesn't have :Man built-in like nvim, so we need to load man.vim first +# Input: $1 = man_page, $2 = search_pattern (vim regex, optional) +# Returns: 0 on success, 1 on failure +zvm_open_vim() { + local man_page="$1" + local search_term="$2" + + # Vim requires loading the man.vim ftplugin first before :Man works + # Set MANWIDTH to prevent line wrapping + # Use /dev/null | ${ZVM_MAN_PAGER} -R -p "${pattern}" 2>/dev/null || \ + MANPAGER=cat man "$man_page" 2>/dev/null | ${ZVM_MAN_PAGER} -R || \ + return 1 + else + MANPAGER=cat man "$man_page" 2>/dev/null | ${ZVM_MAN_PAGER} -R || return 1 + fi + + return 0 +} + +# Main entry point: open man page with appropriate pager +# Automatically detects pager type and uses correct pattern format +# Input: $1 = man_page, $2 = word (for pattern building) +# Returns: 0 on success, 1 on failure +zvm_open_man_page() { + local man_page="$1" + local word="$2" + local pager_type + + pager_type=$(zvm_detect_pager_type) + + case "$pager_type" in + nvim) + local nvim_pattern + nvim_pattern=$(zvm_build_nvim_pattern "$word") + zvm_open_nvim "$man_page" "$nvim_pattern" + ;; + vim) + local vim_pattern + vim_pattern=$(zvm_build_nvim_pattern "$word") + zvm_open_vim "$man_page" "$vim_pattern" + ;; + less|other) + local less_pattern + less_pattern=$(zvm_build_less_pattern "$word") + zvm_open_less "$man_page" "$less_pattern" + ;; + esac +} + diff --git a/plugins/zsh-vi-man/lib/parser.zsh b/plugins/zsh-vi-man/lib/parser.zsh new file mode 100644 index 000000000..bdbc489da --- /dev/null +++ b/plugins/zsh-vi-man/lib/parser.zsh @@ -0,0 +1,49 @@ +# lib/parser.zsh - Word and command parsing utilities +# Extracts words and commands from the command line buffer + +# Get the word at the current cursor position +# Uses LBUFFER and RBUFFER which are ZLE special variables +zvm_parse_word_at_cursor() { + local left="${LBUFFER##*[[:space:]]}" + local right="${RBUFFER%%[[:space:]]*}" + echo "${left}${right}" +} + +# Get the current command segment (handles pipes) +# Returns the text after the last pipe before cursor +zvm_get_current_segment() { + local segment="${LBUFFER##*|}" + # Trim leading whitespace + segment="${segment#"${segment%%[![:space:]]*}"}" + echo "$segment" +} + +# Extract the command name from a segment +# Takes the first word of the segment +zvm_parse_command() { + local segment + segment=$(zvm_get_current_segment) + echo "${segment%%[[:space:]]*}" +} + +# Determine the man page to open, checking for subcommands +# Input: $1 = command, $2 = current_segment +# Output: man page name (e.g., "git-commit" or just "git") +zvm_determine_man_page() { + local cmd="$1" + local segment="$2" + local man_page="$cmd" + + local rest="${segment#*[[:space:]]}" + local potential_subcommand="${rest%%[[:space:]]*}" + + # Check for subcommand man pages (e.g., git-commit, docker-run) + if [[ -n "$potential_subcommand" && ! "$potential_subcommand" =~ ^- ]]; then + if man -w "${cmd}-${potential_subcommand}" &>/dev/null; then + man_page="${cmd}-${potential_subcommand}" + fi + fi + + echo "$man_page" +} + diff --git a/plugins/zsh-vi-man/lib/pattern.zsh b/plugins/zsh-vi-man/lib/pattern.zsh new file mode 100644 index 000000000..1d6912077 --- /dev/null +++ b/plugins/zsh-vi-man/lib/pattern.zsh @@ -0,0 +1,107 @@ +# lib/pattern.zsh - Search pattern builders for different pagers +# Generates regex patterns for option search in man pages + +# Build search pattern for less pager (ERE - Extended Regular Expressions) +# Patterns match option definitions: lines starting with whitespace then dash +# Supports comma-separated (GNU style) and slash-separated (jq style) options +# Input: $1 = word (the option, e.g., "-l", "--recursive", "-rf") +# Output: ERE pattern string +zvm_build_less_pattern() { + local word="$1" + local pattern="" + + if [[ -z "$word" ]]; then + echo "" + return + fi + + # Long option with value: --color=always -> search for --color + # Use -[^[:space:],/]* instead of -.* to prevent matching across descriptions + # Use (-[^[:space:],/]*[,/][[:space:]]+)* to allow multiple preceding options + if [[ "$word" =~ ^--[^=]+= ]]; then + local opt="${word%%=*}" + pattern="^[[:space:]]*${opt}([,/=:[[:space:]]|$)|^[[:space:]]*(-[^[:space:],/]*[,/][[:space:]]+)+${opt}([,/=:[[:space:]]|$)" + + # Combined short options: -rf -> search for -[rf] to find individual options + # Also includes fallback for single-dash long options like find's -name, -type + # Use (-[^[:space:],/]*[,/][[:space:]]+)+ to allow multiple preceding options + elif [[ "$word" =~ ^-[a-zA-Z]{2,}$ ]]; then + local chars="${word:1}" + # Pattern 1: individual chars (e.g., -r or -f from -rf) + # Pattern 2: the full word as-is (e.g., -name for find) + pattern="^[[:space:]]*-[${chars}][,/:[:space:]]|^[[:space:]]*(-[^[:space:],/]*[,/][[:space:]]+)+-[${chars}][,/:[:space:]]|^[[:space:]]*${word}([,/:[:space:]]|$)|^[[:space:]]*(-[^[:space:],/]*[,/][[:space:]]+)+${word}([,/:[:space:]]|$)" + + # Single short option: -r -> match at start of option definition line + # Use (-[^[:space:],/]*[,/][[:space:]]+)+ to allow multiple preceding options + elif [[ "$word" =~ ^-[a-zA-Z]$ ]]; then + pattern="^[[:space:]]*${word}[,/:[:space:]]|^[[:space:]]*(-[^[:space:],/]*[,/][[:space:]]+)+${word}([,/:[:space:]]|$)" + + # Long option without value: --recursive + # Use (-[^[:space:],/]*[,/][[:space:]]+)+ to allow multiple preceding options + elif [[ "$word" =~ ^-- ]]; then + pattern="^[[:space:]]*${word}([,/=:[[:space:]]|$)|^[[:space:]]*(-[^[:space:],/]*[,/][[:space:]]+)+${word}([,/=:[[:space:]]|$)" + fi + + echo "$pattern" +} + +# Build search pattern for vim/neovim (Vim regex syntax) +# Matches option definitions: lines starting with whitespace then dash +# Supports comma-separated (GNU style) and slash-separated (jq style) options +# Uses word boundaries to prevent partial matches (e.g., --slurp vs --slurpfile) +# Input: $1 = word (the option, e.g., "-l", "--recursive", "-rf") +# Output: Vim search pattern string +zvm_build_nvim_pattern() { + local word="$1" + local search_term="" + + if [[ -z "$word" ]]; then + echo "" + return + fi + + # Long option with value: --color=always -> search for --color + # Match: at line start OR after comma/slash separator + # End: followed by delimiter (comma, slash, equals, colon, space) or EOL + # Use \(-[^[:space:],/]*[,/][[:space:]]*\)\+ to allow multiple preceding options + if [[ "$word" =~ ^--[^=]+= ]]; then + local opt="${word%%=*}" + search_term="^[[:space:]]*${opt}\\([,/=:[[:space:]]\\|$\\)\\|^[[:space:]]*\\(-[^[:space:],/]*[,/][[:space:]]*\\)\\+${opt}\\([,/=:[[:space:]]\\|$\\)" + + # Combined short options: -la -> search for -l or -a + # Also includes the full word as fallback for find-style -name, -type + # Use \(-[^[:space:],/]*[,/][[:space:]]*\)\+ to allow multiple preceding options + elif [[ "$word" =~ ^-[a-zA-Z]{2,}$ ]]; then + local chars="${word:1}" + local alternation="" + # Pattern for individual chars (e.g., -r or -f from -rf) + for (( i=0; i<${#chars}; i++ )); do + local char="-${chars:$i:1}" + if [[ -n "$alternation" ]]; then + alternation="${alternation}\\|^[[:space:]]*${char}[,/:[:space:]]\\|^[[:space:]]*\\(-[^[:space:],/]*[,/][[:space:]]*\\)\\+${char}\\([,/:[:space:]]\\|$\\)" + else + alternation="^[[:space:]]*${char}[,/:[:space:]]\\|^[[:space:]]*\\(-[^[:space:],/]*[,/][[:space:]]*\\)\\+${char}\\([,/:[:space:]]\\|$\\)" + fi + done + # Add full word as fallback (for find-style -name, -exec, etc.) + alternation="${alternation}\\|^[[:space:]]*${word}\\([,/:[:space:]]\\|$\\)\\|^[[:space:]]*\\(-[^[:space:],/]*[,/][[:space:]]*\\)\\+${word}\\([,/:[:space:]]\\|$\\)" + search_term="${alternation}" + + # Single short option: -r + # Match: at line start OR after comma/slash separator + # End: followed by delimiter or EOL + # Use \(-[^[:space:],/]*[,/][[:space:]]*\)\+ to allow multiple preceding options + elif [[ "$word" =~ ^-[a-zA-Z]$ ]]; then + search_term="^[[:space:]]*${word}[,/:[:space:]]\\|^[[:space:]]*\\(-[^[:space:],/]*[,/][[:space:]]*\\)\\+${word}\\([,/:[:space:]]\\|$\\)" + + # Long option without value: --recursive + # Match: at line start OR after comma/slash separator + # End: followed by delimiter or EOL (prevents --slurp matching --slurpfile) + # Use \(-[^[:space:],/]*[,/][[:space:]]*\)\+ to allow multiple preceding options + elif [[ "$word" =~ ^-- ]]; then + search_term="^[[:space:]]*${word}\\([,/=:[[:space:]]\\|$\\)\\|^[[:space:]]*\\(-[^[:space:],/]*[,/][[:space:]]*\\)\\+${word}\\([,/=:[[:space:]]\\|$\\)" + fi + + echo "$search_term" +} + diff --git a/plugins/zsh-vi-man/zsh-vi-man.plugin.zsh b/plugins/zsh-vi-man/zsh-vi-man.plugin.zsh new file mode 100644 index 000000000..3141798a6 --- /dev/null +++ b/plugins/zsh-vi-man/zsh-vi-man.plugin.zsh @@ -0,0 +1,65 @@ +# zsh-vi-man plugin +# Smart man page lookup for zsh vi mode (now with emacs mode support!) +# +# Press K in vi normal mode, Ctrl-X k in emacs mode, or Ctrl-K in vi insert mode +# to open the man page for the current command. If your cursor is on an option +# (like -r or --recursive), it will jump directly to that option in the man page. +# +# https://github.com/TunaCuma/zsh-vi-man +# MIT License - Copyright (c) 2025 Tuna Cuma + +# Configuration variables (can be set before sourcing) +# ZVM_MAN_KEY: the key to trigger man page lookup in vi normal mode (default: K) +# ZVM_MAN_KEY_EMACS: the key sequence for emacs mode (default: ^Xk, i.e., Ctrl-X k) +# ZVM_MAN_KEY_INSERT: the key for vi insert mode (default: ^K, i.e., Ctrl-K) +# ZVM_MAN_PAGER: the pager to use (default: less, supports nvim/vim) +# ZVM_MAN_ENABLE_EMACS: enable emacs mode binding (default: true) +# ZVM_MAN_ENABLE_INSERT: enable vi insert mode binding (default: true) + +: ${ZVM_MAN_KEY:=K} +: ${ZVM_MAN_KEY_EMACS:='^Xk'} +: ${ZVM_MAN_KEY_INSERT:='^K'} +: ${ZVM_MAN_PAGER:=less} +: ${ZVM_MAN_ENABLE_EMACS:=true} +: ${ZVM_MAN_ENABLE_INSERT:=true} + +# Get the directory where this script is located +typeset -g ZVM_LIB_DIR="${0:A:h}/lib" + +# Source modular components +source "${ZVM_LIB_DIR}/parser.zsh" +source "${ZVM_LIB_DIR}/pattern.zsh" +source "${ZVM_LIB_DIR}/pager.zsh" +source "${ZVM_LIB_DIR}/keybinding.zsh" + +# Main widget function - orchestrates the man page lookup +function zvm-man() { + # Parse current context + local word=$(zvm_parse_word_at_cursor) + local cmd=$(zvm_parse_command) + + if [[ -z "$cmd" ]]; then + zle -M "No command found" + return 1 + fi + + # Determine the man page to open (may include subcommand) + local current_segment=$(zvm_get_current_segment) + local man_page=$(zvm_determine_man_page "$cmd" "$current_segment") + + # Clear screen and open man page with appropriate pager + zle -I + + if ! zvm_open_man_page "$man_page" "$word"; then + zle -M "No manual entry for ${man_page}" + fi + + zle reset-prompt +} + +# Register the widget +zle -N zvm-man + +# Setup keybindings +zvm_setup_keybindings +