feat(zsh-vi-man): add plugin for smart man page lookup

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)
This commit is contained in:
Tuna Cuma 2026-01-04 20:41:28 +03:00
commit 89e0439e21
6 changed files with 511 additions and 0 deletions

View file

@ -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
}

View file

@ -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 <cmd>" +only +/<search>
# 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/tty to ensure vim gets proper terminal access from zle widget
if [[ -n "$search_term" ]]; then
MANWIDTH=999 ${ZVM_MAN_PAGER} \
+"runtime ftplugin/man.vim" \
+"Man ${man_page}" \
+only \
+"/${search_term}" </dev/tty || \
MANWIDTH=999 ${ZVM_MAN_PAGER} \
+"runtime ftplugin/man.vim" \
+"Man ${man_page}" \
+only </dev/tty || \
return 1
else
MANWIDTH=999 ${ZVM_MAN_PAGER} \
+"runtime ftplugin/man.vim" \
+"Man ${man_page}" \
+only </dev/tty || \
return 1
fi
return 0
}
# Open man page with less or other traditional pagers
# Uses the -p flag for pattern search
# Input: $1 = man_page, $2 = search_pattern (ERE, optional)
# Returns: 0 on success, 1 on failure
zvm_open_less() {
local man_page="$1"
local pattern="$2"
# Always pipe through ZVM_MAN_PAGER to override system MANPAGER
# This prevents issues when MANPAGER is set to nvim/vim but ZVM_MAN_PAGER is less
# Use -R to pass through raw escape sequences (needed for LESS_TERMCAP_* colors)
if [[ -n "$pattern" ]]; then
MANPAGER=cat man "$man_page" 2>/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
}

View file

@ -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"
}

View file

@ -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"
}