|
#!/usr/bin/env bash |
|
# rgfzf.sh - an aggressively useful ripgrep + fzf + bat UX wrapper. |
|
# Portable target: Linux + macOS, Bash 3.2+ compatible enough for macOS system Bash. |
|
|
|
set -u |
|
|
|
SELF=${0} |
|
|
|
usage() { |
|
cat <<'USAGE' |
|
rgfzf - interactive ripgrep/fzf code search |
|
|
|
Usage: |
|
rgfzf [options] [initial query] [-- paths...] |
|
|
|
Options: |
|
-h, --help Show help |
|
-H, --hidden Include hidden files/directories |
|
-u, --unrestricted Search hidden + ignored files (-uu) |
|
-F, --fixed Treat query as a literal string |
|
-w, --word Match whole words only |
|
-P, --pcre2 Use PCRE2 regex engine |
|
-L, --follow Follow symbolic links |
|
-t, --type TYPE Include rg file type, repeatable (ex: -t php -t java) |
|
-T, --type-not TYPE Exclude rg file type, repeatable |
|
-g, --glob GLOB Include/exclude glob, repeatable (ex: -g '!vendor/**') |
|
--editor CMD Editor command. Defaults to $RGFZF_EDITOR, $VISUAL, $EDITOR, then auto-detect. |
|
--no-bat Use sed/awk preview instead of bat |
|
--print Print selected file:line:column instead of opening |
|
|
|
Environment: |
|
RGFZF_EDITOR Preferred editor command |
|
RGFZF_HEIGHT fzf height, default 95% |
|
RGFZF_LAYOUT fzf layout, default reverse |
|
RGFZF_BAT_STYLE bat style, default full |
|
RGFZF_BAT_THEME optional bat theme |
|
RGFZF_PREVIEW_CONTEXT preview context lines, default 180 |
|
|
|
Key bindings: |
|
Enter open selection and exit |
|
Ctrl-o open selection and stay in fzf |
|
Ctrl-y copy file:line:column |
|
Ctrl-p print file:line:column and exit |
|
Ctrl-/ toggle preview |
|
Ctrl-w toggle preview wrap |
|
Ctrl-r reload current query |
|
Alt-a select all matches |
|
Alt-d deselect all |
|
Alt-up/down scroll preview |
|
Alt-left/right resize preview |
|
|
|
Notes: |
|
- Multi-select with Tab/Shift-Tab. Enter opens quickfix-style when supported. |
|
- For live search, fzf filtering is disabled and rg owns the query. |
|
- Search output is rg vimgrep-ish: file:line:column:text. |
|
USAGE |
|
} |
|
|
|
have() { command -v "$1" >/dev/null 2>&1; } |
|
|
|
bat_cmd() { |
|
if [ "${RGFZF_NO_BAT:-0}" = "1" ]; then return 1; fi |
|
if have bat; then printf '%s\n' bat; return 0; fi |
|
if have batcat; then printf '%s\n' batcat; return 0; fi |
|
return 1 |
|
} |
|
|
|
editor_cmd() { |
|
if [ -n "${RGFZF_EDITOR:-}" ]; then printf '%s\n' "$RGFZF_EDITOR"; return; fi |
|
if [ -n "${VISUAL:-}" ]; then printf '%s\n' "$VISUAL"; return; fi |
|
if [ -n "${EDITOR:-}" ]; then printf '%s\n' "$EDITOR"; return; fi |
|
for e in nvim vim code cursor zed hx vi; do |
|
if have "$e"; then printf '%s\n' "$e"; return; fi |
|
done |
|
printf '%s\n' vi |
|
} |
|
|
|
copy_cmd() { |
|
if have pbcopy; then printf '%s\n' pbcopy; return 0; fi |
|
if have wl-copy; then printf '%s\n' wl-copy; return 0; fi |
|
if have xclip; then printf '%s\n' 'xclip -selection clipboard'; return 0; fi |
|
if have xsel; then printf '%s\n' 'xsel --clipboard --input'; return 0; fi |
|
return 1 |
|
} |
|
|
|
# Internal search. Arguments after -- are rg args; query is in RGFZF_QUERY. |
|
internal_search() { |
|
query=${RGFZF_QUERY:-} |
|
shift # --search |
|
# Avoid rg showing usage/error noise for empty initial query. Empty regex means "all lines". |
|
rg --vimgrep --column --line-number --no-heading --color=always --smart-case "$@" -- "$query" 2>/dev/null || true |
|
} |
|
|
|
internal_preview() { |
|
# Receives one full selected line. Parse file:line:column:rest. |
|
shift # --preview |
|
item=${1:-} |
|
file=${item%%:*} |
|
rest=${item#*:} |
|
line=${rest%%:*} |
|
rest=${rest#*:} |
|
col=${rest%%:*} |
|
|
|
if [ -z "$file" ] || [ ! -f "$file" ]; then |
|
printf 'No file selected yet.\n' |
|
return 0 |
|
fi |
|
case "$line" in ''|*[!0-9]*) line=1 ;; esac |
|
|
|
context=${RGFZF_PREVIEW_CONTEXT:-180} |
|
start=$((line - context / 2)) |
|
[ "$start" -lt 1 ] && start=1 |
|
end=$((line + context / 2)) |
|
|
|
if b=$(bat_cmd); then |
|
style=${RGFZF_BAT_STYLE:-full} |
|
theme_arg= |
|
if [ -n "${RGFZF_BAT_THEME:-}" ]; then theme_arg="--theme=$RGFZF_BAT_THEME"; fi |
|
# shellcheck disable=SC2086 |
|
"$b" --color=always --paging=never --style="$style" $theme_arg \ |
|
--highlight-line "$line" --line-range "${start}:${end}" -- "$file" 2>/dev/null || true |
|
else |
|
awk -v s="$start" -v e="$end" -v h="$line" ' |
|
NR>=s && NR<=e { |
|
prefix = (NR == h ? ">" : " "); |
|
printf "%s %6d │ %s\n", prefix, NR, $0 |
|
} |
|
' "$file" 2>/dev/null || true |
|
fi |
|
} |
|
|
|
quote_for_sh() { |
|
# POSIX-ish single quote escaping. |
|
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\''/g")" |
|
} |
|
|
|
open_one() { |
|
file=$1 line=${2:-1} col=${3:-1} |
|
ed=$(editor_cmd) |
|
base=${ed%% *} |
|
|
|
case "$base" in |
|
code|cursor) |
|
$ed --goto "$file:$line:$col" >/dev/null 2>&1 & |
|
;; |
|
zed) |
|
$ed "$file:$line:$col" >/dev/null 2>&1 & |
|
;; |
|
hx|helix) |
|
$ed "$file:$line:$col" |
|
;; |
|
vim|nvim|vi|mvim) |
|
$ed "+call cursor($line,$col)" -- "$file" |
|
;; |
|
*) |
|
$ed "$file" |
|
;; |
|
esac |
|
} |
|
|
|
internal_open() { |
|
mode=${1:-} |
|
shift || true |
|
tmp=${1:-} |
|
if [ -n "$tmp" ] && [ -f "$tmp" ]; then |
|
count=$(wc -l < "$tmp" | tr -d ' ') |
|
ed=$(editor_cmd) |
|
base=${ed%% *} |
|
if [ "$count" -gt 1 ]; then |
|
case "$base" in |
|
vim|nvim|vi|mvim) |
|
$ed +cw -q "$tmp" |
|
return $? |
|
;; |
|
esac |
|
fi |
|
first=$(sed -n '1p' "$tmp") |
|
else |
|
first=${tmp:-} |
|
fi |
|
|
|
file=${first%%:*} |
|
rest=${first#*:} |
|
line=${rest%%:*} |
|
rest=${rest#*:} |
|
col=${rest%%:*} |
|
[ -n "$file" ] && open_one "$file" "$line" "$col" |
|
} |
|
|
|
internal_copy() { |
|
shift # --copy |
|
item=${1:-} |
|
target=$(printf '%s' "$item" | awk -F: '{print $1":"$2":"$3}') |
|
if c=$(copy_cmd); then |
|
# shellcheck disable=SC2086 |
|
printf '%s' "$target" | $c |
|
else |
|
printf '%s\n' "$target" |
|
fi |
|
} |
|
|
|
internal_print() { |
|
shift # --print-item |
|
item=${1:-} |
|
printf '%s\n' "$item" | awk -F: '{print $1":"$2":"$3}' |
|
} |
|
|
|
main() { |
|
if [ "${1:-}" = "--search" ]; then internal_search "$@"; exit 0; fi |
|
if [ "${1:-}" = "--preview" ]; then internal_preview "$@"; exit 0; fi |
|
if [ "${1:-}" = "--open" ]; then internal_open "$@"; exit 0; fi |
|
if [ "${1:-}" = "--copy" ]; then internal_copy "$@"; exit 0; fi |
|
if [ "${1:-}" = "--print-item" ]; then internal_print "$@"; exit 0; fi |
|
|
|
have rg || { printf 'rgfzf: missing dependency: rg\n' >&2; exit 127; } |
|
have fzf || { printf 'rgfzf: missing dependency: fzf\n' >&2; exit 127; } |
|
|
|
rg_args='' |
|
paths='' |
|
query_parts='' |
|
print_only=0 |
|
|
|
while [ "$#" -gt 0 ]; do |
|
case "$1" in |
|
-h|--help) usage; exit 0 ;; |
|
-H|--hidden) rg_args="$rg_args --hidden"; shift ;; |
|
-u|--unrestricted) rg_args="$rg_args -uu"; shift ;; |
|
-F|--fixed) rg_args="$rg_args --fixed-strings"; shift ;; |
|
-w|--word) rg_args="$rg_args --word-regexp"; shift ;; |
|
-P|--pcre2) rg_args="$rg_args --pcre2"; shift ;; |
|
-L|--follow) rg_args="$rg_args --follow"; shift ;; |
|
-t|--type) shift; [ "$#" -gt 0 ] || { printf 'rgfzf: --type needs value\n' >&2; exit 2; }; rg_args="$rg_args --type $(quote_for_sh "$1")"; shift ;; |
|
-T|--type-not) shift; [ "$#" -gt 0 ] || { printf 'rgfzf: --type-not needs value\n' >&2; exit 2; }; rg_args="$rg_args --type-not $(quote_for_sh "$1")"; shift ;; |
|
-g|--glob) shift; [ "$#" -gt 0 ] || { printf 'rgfzf: --glob needs value\n' >&2; exit 2; }; rg_args="$rg_args --glob $(quote_for_sh "$1")"; shift ;; |
|
--editor) shift; [ "$#" -gt 0 ] || { printf 'rgfzf: --editor needs value\n' >&2; exit 2; }; export RGFZF_EDITOR=$1; shift ;; |
|
--no-bat) export RGFZF_NO_BAT=1; shift ;; |
|
--print) print_only=1; shift ;; |
|
--) shift; while [ "$#" -gt 0 ]; do paths="$paths $(quote_for_sh "$1")"; shift; done ;; |
|
*) query_parts="${query_parts}${query_parts:+ }$1"; shift ;; |
|
esac |
|
done |
|
|
|
# Search command executed by fzf. We intentionally go through this script so |
|
# all quoting, flags, and future behavior stay centralized. |
|
search_cmd="RGFZF_QUERY={q} $(quote_for_sh "$SELF") --search $rg_args $paths" |
|
preview_cmd="$(quote_for_sh "$SELF") --preview {}" |
|
copy_cmd_bind="$(quote_for_sh "$SELF") --copy {}" |
|
print_cmd_bind="$(quote_for_sh "$SELF") --print-item {}" |
|
|
|
# fzf {+f} writes selected lines to a temp file; ideal for Vim quickfix. |
|
opener="$(quote_for_sh "$SELF") --open enter {+f}" |
|
executor="$(quote_for_sh "$SELF") --open execute {+f}" |
|
|
|
common_binds="start:reload:$search_cmd || true,change:reload:sleep 0.08; $search_cmd || true,ctrl-r:reload:$search_cmd || true,ctrl-o:execute:$executor,ctrl-y:execute-silent:$copy_cmd_bind,ctrl-p:become:$print_cmd_bind,ctrl-/:toggle-preview,ctrl-w:toggle-preview-wrap,alt-a:select-all,alt-d:deselect-all,alt-up:preview-up,alt-down:preview-down,alt-pgup:preview-page-up,alt-pgdn:preview-page-down,alt-left:change-preview-window(down,70%,border-top|right,60%,border-left|hidden),alt-right:change-preview-window(right,70%,border-left|down,70%,border-top|hidden)" |
|
|
|
if [ "$print_only" = "1" ]; then |
|
enter_bind="enter:become:$print_cmd_bind" |
|
else |
|
enter_bind="enter:become:$opener" |
|
fi |
|
|
|
height=${RGFZF_HEIGHT:-95%} |
|
layout=${RGFZF_LAYOUT:-reverse} |
|
|
|
fzf --ansi --disabled --multi --cycle --layout="$layout" --height="$height" \ |
|
--border --padding=1 \ |
|
--prompt='rg › ' \ |
|
--pointer='▶' --marker='✓' \ |
|
--header='type to rg live • Enter open • Ctrl-o open/stay • Ctrl-y copy loc • Ctrl-p print • Ctrl-/ preview • Alt-a/d all/none' \ |
|
--delimiter=':' \ |
|
--with-nth='1,2,3,4..' \ |
|
--preview="$preview_cmd" \ |
|
--preview-label=' preview ' \ |
|
--preview-window='right,60%,border-left,~4,+{2}+4/3,<100(up,60%,border-bottom)' \ |
|
--bind="$common_binds" \ |
|
--bind="$enter_bind" \ |
|
--query="$query_parts" |
|
} |
|
|
|
main "$@" |