diff --git a/README.md b/README.md index c57f7d64..5f7d4a8c 100644 --- a/README.md +++ b/README.md @@ -69,15 +69,17 @@ Table of Contents * [Demo](#demo) * [Examples](#examples) * [Key bindings for command-line](#key-bindings-for-command-line) -* [Fuzzy completion for bash and zsh](#fuzzy-completion-for-bash-and-zsh) +* [Fuzzy completion](#fuzzy-completion) * [Files and directories](#files-and-directories) * [Process IDs](#process-ids) * [Host names](#host-names) * [Environment variables / Aliases](#environment-variables--aliases) - * [Customizing fzf options for completion](#customizing-fzf-options-for-completion) - * [Customizing completion source for paths and directories](#customizing-completion-source-for-paths-and-directories) - * [Supported commands](#supported-commands) - * [Custom fuzzy completion](#custom-fuzzy-completion) + * [Customizing fuzzy completion for bash and zsh](#customizing-fuzzy-completion-for-bash-and-zsh) + * [Customizing fzf options for completion](#customizing-fzf-options-for-completion) + * [Customizing completion source for paths and directories](#customizing-completion-source-for-paths-and-directories) + * [Supported commands (bash)](#supported-commands-bash) + * [Custom fuzzy completion](#custom-fuzzy-completion) + * [Fuzzy completion for fish](#fuzzy-completion-for-fish) * [Vim plugin](#vim-plugin) * [Advanced topics](#advanced-topics) * [Customizing for different types of input](#customizing-for-different-types-of-input) @@ -556,8 +558,10 @@ Display modes for these bindings can be separately configured via More tips can be found on [the wiki page](https://github.com/junegunn/fzf/wiki/Configuring-shell-key-bindings). -Fuzzy completion for bash and zsh ---------------------------------- +Fuzzy completion +---------------- + +Shell integration also provides fuzzy completion for bash, zsh, and fish. ### Files and directories @@ -599,23 +603,28 @@ kill -9 ** ### Host names -For ssh and telnet commands, fuzzy completion for hostnames is provided. The -names are extracted from /etc/hosts and ~/.ssh/config. +For ssh command, fuzzy completion for hostnames is provided. The names are +extracted from /etc/hosts and ~/.ssh/config. ```sh ssh ** -telnet ** ``` ### Environment variables / Aliases ```sh +# bash and zsh unset ** export ** unalias ** + +# fish +set ``` -### Customizing fzf options for completion +### Customizing fuzzy completion for bash and zsh + +#### Customizing fzf options for completion ```sh # Use ~~ as the trigger sequence instead of the default ** @@ -646,7 +655,7 @@ _fzf_comprun() { } ``` -### Customizing completion source for paths and directories +#### Customizing completion source for paths and directories ```sh # Use fd (https://github.com/sharkdp/fd) for listing path candidates. @@ -662,7 +671,7 @@ _fzf_compgen_dir() { } ``` -### Supported commands +#### Supported commands (bash) On bash, fuzzy completion is enabled only for a predefined set of commands (`complete | grep _fzf` to see the list). But you can enable it for other @@ -674,7 +683,7 @@ _fzf_setup_completion path ag git kubectl _fzf_setup_completion dir tree ``` -### Custom fuzzy completion +#### Custom fuzzy completion _**(Custom completion API is experimental and subject to change)**_ @@ -724,6 +733,65 @@ _fzf_complete_foo_post() { [ -n "$BASH" ] && complete -F _fzf_complete_foo -o default -o bashdefault foo ``` +### Fuzzy completion for fish + +(Available in 0.68.0 or later) + +Fuzzy completion for fish differs from bash and zsh in that: + +- It doesn't require a trigger sequence like `**`. Instead, if activates + on `Shift-TAB`, while `TAB` preserves fish's native completion behavior. +- It relies on fish's native completion system to populate the candidate list, + rather than performing a recursive file system traversal. For recursive + searching, use the `CTRL-T` binding instead. +- The only supported configuration variable is `FZF_COMPLETION_OPTS`. + +That said, just like in bash and zsh, you can implement custom completion for +a specific command by defining an `_fzf_complete_COMMAND` function. For example: + +```fish +function _fzf_complete_foo + function _fzf_complete_foo_post + awk '{print $NF}' + end + _fzf_complete --multi --reverse --header-lines=3 -- $argv < (ls -al | psub) + + functions -e _fzf_complete_foo_post +end +``` + +And here's a more complex example for customizing `git` + +```fish +function _fzf_complete_git + switch $argv[2] + case checkout switch + _fzf_complete --reverse --no-preview -- $argv < (git branch --all --format='%(refname:short)' | psub) + + case add + function _fzf_complete_git_post + awk '{print $NF}' + end + _fzf_complete --multi --reverse -- $argv < (git status --short | psub) + + case show log diff + function _fzf_complete_git_post + awk '{print $1}' + end + _fzf_complete --reverse --no-sort --preview='git show --color=always {1}' -- $argv < (git log --oneline | psub) + + case '' + __fzf_complete_native "$argv[1] " --query=(commandline -t | string escape) + + case '*' + set -l -- current_token (commandline -t) + __fzf_complete_native "$argv $current_token" --query=(string escape -- $current_token) --multi + end + + functions -e _fzf_complete_git_post +end +``` + Vim plugin ---------- diff --git a/install b/install index 6d0149e4..66fe4d20 100755 --- a/install +++ b/install @@ -243,16 +243,16 @@ fi echo for shell in $shells; do + fzf_completion="source \"$fzf_base/shell/completion.${shell}\"" + fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\"" [[ $shell == fish ]] && continue src=${prefix_expand}.${shell} echo -n "Generate $src ... " - fzf_completion="source \"$fzf_base/shell/completion.${shell}\"" if [ $auto_completion -eq 0 ]; then fzf_completion="# $fzf_completion" fi - fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\"" if [ $key_bindings -eq 0 ]; then fzf_key_bindings="# $fzf_key_bindings" fi @@ -302,15 +302,16 @@ append_line() { line="$2" file="$3" pat="${4:-}" + at_lno="${5:-}" lines="" echo "Update $file:" echo " - $line" if [ -f "$file" ]; then - if [ $# -lt 4 ]; then - lines=$(\grep -nF "$line" "$file") - else + if [[ -n $pat ]]; then lines=$(\grep -nF "$pat" "$file") + else + lines=$(\grep -nF "${line#"${line%%[![:space:]]*}"}" "$file") fi fi @@ -328,8 +329,12 @@ append_line() { set -e if [ "$update" -eq 1 ]; then - [ -f "$file" ] && echo >> "$file" - echo "$line" >> "$file" + if [[ -z $at_lno ]]; then + [ -f "$file" ] && echo >> "$file" + echo "$line" >> "$file" + else + sed -i.~fzf_bak "${at_lno}a\\"$'\n'"$line" "$file" && rm "$file.~fzf_bak" + fi echo " + Added" else echo " ~ Skipped" @@ -362,25 +367,66 @@ for shell in $shells; do append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}" done -if [ $key_bindings -eq 1 ] && [[ $shells =~ fish ]]; then +if [[ $shells =~ fish ]]; then bind_file="${fish_dir}/functions/fish_user_key_bindings.fish" if [ ! -e "$bind_file" ]; then - mkdir -p "${fish_dir}/functions" - create_file "$bind_file" \ - 'function fish_user_key_bindings' \ - ' fzf --fish | source' \ - 'end' + if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then + mkdir -p "${fish_dir}/functions" + if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then + create_file "$bind_file" \ + 'function fish_user_key_bindings' \ + ' fzf --fish | source' \ + 'end' + elif [[ $key_bindings -eq 1 ]]; then + create_file "$bind_file" \ + 'function fish_user_key_bindings' \ + " $fzf_key_bindings" \ + 'end' + elif [[ $auto_completion -eq 1 ]]; then + create_file "$bind_file" \ + 'function fish_user_key_bindings' \ + " $fzf_completion" \ + 'end' + fi + lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') + else + lno_func=0 + fi else echo "Check $bind_file:" - lno=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') - if [[ -n $lno ]]; then - echo " ** Found 'fzf_key_bindings' in line #$lno" - echo " ** You have to replace the line to 'fzf --fish | source'" + lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') + if [[ -z $lno_func ]]; then + echo -e "function fish_user_key_bindings\nend" >> "$bind_file" + lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') + fi + lno_keys=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') + if [[ -n $lno_keys ]]; then + echo " ** Found 'fzf_key_bindings' in line #$lno_keys" + if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then + echo " ** You have to replace the line to 'fzf --fish | source'" + elif [[ $key_bindings -eq 1 ]]; then + echo " ** You have to replace the line to '$fzf_key_bindings'" + else + echo " ** You have to remove the line" + fi echo else echo " - Clear" echo - append_line $update_config "fzf --fish | source" "$bind_file" + if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then + sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak" + sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak" + append_line $update_config " fzf --fish | source" "$bind_file" "" "$lno_func" + else + sed -i.~fzf_bak '/fzf --fish \| source/d' "$bind_file" && rm "$bind_file.~fzf_bak" + if [[ $key_bindings -eq 1 ]]; then + sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak" + append_line $update_config " $fzf_key_bindings" "$bind_file" "" "$lno_func" + elif [[ $auto_completion -eq 1 ]]; then + sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak" + append_line $update_config " $fzf_completion" "$bind_file" "" "$lno_func" + fi + fi fi fi fi @@ -393,7 +439,7 @@ if [ $update_config -eq 1 ]; then echo fi [[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh" - [[ $shells =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_key_bindings # fish' + [[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fzf_user_key_bindings # fish' echo echo 'Use uninstall script to remove fzf.' echo diff --git a/main.go b/main.go index 0a2f9ade..d5e019fd 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,9 @@ var zshCompletion []byte //go:embed shell/key-bindings.fish var fishKeyBindings []byte +//go:embed shell/completion.fish +var fishCompletion []byte + //go:embed man/man1/fzf.1 var manPage []byte @@ -65,7 +68,7 @@ func main() { } if options.Fish { printScript("key-bindings.fish", fishKeyBindings) - fmt.Println("fzf_key_bindings") + printScript("completion.fish", fishCompletion) return } if options.Help { diff --git a/shell/common.fish b/shell/common.fish new file mode 100644 index 00000000..b6b7e0bb --- /dev/null +++ b/shell/common.fish @@ -0,0 +1,141 @@ + function __fzf_defaults + # $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS + # $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS + test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40% + string join ' ' -- \ + "--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \ + (test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \ + $FZF_DEFAULT_OPTS $argv[2..-1] + end + + function __fzfcmd + test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40% + if test -n "$FZF_TMUX_OPTS" + echo "fzf-tmux $FZF_TMUX_OPTS -- " + else if test "$FZF_TMUX" = "1" + echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- " + else + echo "fzf" + end + end + + function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes' + # Get tokens - use version-appropriate flags + set -l tokens + if test (string match -r -- '^\d+' $version) -ge 4 + set -- tokens (commandline -xpc) + else + set -- tokens (commandline -opc) + end + + # Filter out leading environment variable assignments + set -l -- var_count 0 + for i in $tokens + if string match -qr -- '^[\w]+=' $i + set var_count (math $var_count + 1) + else + break + end + end + set -e -- tokens[0..$var_count] + + # Skip command prefixes so callers see the actual command name, + # e.g. "builtin cd" → "cd", "env VAR=1 command cd" → "cd" + while true + switch "$tokens[1]" + case builtin command + set -e -- tokens[1] + test "$tokens[1]" = "--"; and set -e -- tokens[1] + case env + set -e -- tokens[1] + test "$tokens[1]" = "--"; and set -e -- tokens[1] + while string match -qr -- '^[\w]+=' "$tokens[1]" + set -e -- tokens[1] + end + case '*' + break + end + end + + string escape -n -- $tokens + end + + function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix' + set -l fzf_query '' + set -l prefix '' + set -l dir '.' + + # Set variables containing the major and minor fish version numbers, using + # a method compatible with all supported fish versions. + set -l -- fish_major (string match -r -- '^\d+' $version) + set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2] + + # fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded. + set -l -- match_regex '(?[\s\S]*?(?=\n?$)$)' + set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S' + if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3 + or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p)) + set -- match_regex "(?$prefix_regex)?$match_regex" + end + + # Set $prefix and expanded $fzf_query with preserved trailing newlines. + if test "$fish_major" -ge 4 + # fish v4.0.0 and newer + string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N) + else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 + # fish v3.2.0 - v3.7.1 (last v3) + string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N) + eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '') + else + # fish older than v3.2.0 (v3.1b1 - v3.1.2) + set -l -- cl_token (commandline --current-token --tokenize | string collect -N) + set -- prefix (string match -r -- $prefix_regex $cl_token) + set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N) + eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '') + end + + if test -n "$fzf_query" + # Normalize path in $fzf_query, set $dir to the longest existing directory. + if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \) + # fish v3.5.0 and newer + set -- fzf_query (path normalize -- $fzf_query) + set -- dir $fzf_query + while not path is -d $dir + set -- dir (path dirname $dir) + end + else + # fish older than v3.5.0 (v3.1b1 - v3.4.1) + if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 + # fish v3.2.0 - v3.4.1 + string match -q -r -- '(?^[\s\S]*?(?=\n?$)$)' \ + (string replace -r -a -- '(?<=/)/|(?[\s\S]*)' $fzf_query + else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 + # fish v3.2.0 - v3.7.1 (last v3) + string match -q -r -- '^/?(?[\s\S]*?(?=\n?$)$)' \ + (string replace -- "$dir" '' $fzf_query | string collect -N) + else + # fish older than v3.2.0 (v3.1b1 - v3.1.2) + set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N) + eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '') + end + end + end + + string escape -n -- "$dir" "$fzf_query" "$prefix" + end diff --git a/shell/completion.fish b/shell/completion.fish new file mode 100644 index 00000000..a1281c31 --- /dev/null +++ b/shell/completion.fish @@ -0,0 +1,241 @@ +# ____ ____ +# / __/___ / __/ +# / /_/_ / / /_ +# / __/ / /_/ __/ +# /_/ /___/_/ completion.fish +# +# - $FZF_COMPLETION_OPTS (default: empty) + +function fzf_completion_setup + +#----BEGIN INCLUDE common.fish +# NOTE: Do not directly edit this section, which is copied from "common.fish". +# To modify it, one can edit "common.fish" and run "./update.sh" to apply +# the changes. See code comments in "common.fish" for the implementation details. + + function __fzf_defaults + test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40% + string join ' ' -- \ + "--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \ + (test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \ + $FZF_DEFAULT_OPTS $argv[2..-1] + end + + function __fzfcmd + test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40% + if test -n "$FZF_TMUX_OPTS" + echo "fzf-tmux $FZF_TMUX_OPTS -- " + else if test "$FZF_TMUX" = "1" + echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- " + else + echo "fzf" + end + end + + function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes' + set -l tokens + if test (string match -r -- '^\d+' $version) -ge 4 + set -- tokens (commandline -xpc) + else + set -- tokens (commandline -opc) + end + + set -l -- var_count 0 + for i in $tokens + if string match -qr -- '^[\w]+=' $i + set var_count (math $var_count + 1) + else + break + end + end + set -e -- tokens[0..$var_count] + + while true + switch "$tokens[1]" + case builtin command + set -e -- tokens[1] + test "$tokens[1]" = "--"; and set -e -- tokens[1] + case env + set -e -- tokens[1] + test "$tokens[1]" = "--"; and set -e -- tokens[1] + while string match -qr -- '^[\w]+=' "$tokens[1]" + set -e -- tokens[1] + end + case '*' + break + end + end + + string escape -n -- $tokens + end + + function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix' + set -l fzf_query '' + set -l prefix '' + set -l dir '.' + + set -l -- fish_major (string match -r -- '^\d+' $version) + set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2] + + set -l -- match_regex '(?[\s\S]*?(?=\n?$)$)' + set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S' + if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3 + or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p)) + set -- match_regex "(?$prefix_regex)?$match_regex" + end + + if test "$fish_major" -ge 4 + string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N) + else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 + string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N) + eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '') + else + set -l -- cl_token (commandline --current-token --tokenize | string collect -N) + set -- prefix (string match -r -- $prefix_regex $cl_token) + set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N) + eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '') + end + + if test -n "$fzf_query" + if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \) + set -- fzf_query (path normalize -- $fzf_query) + set -- dir $fzf_query + while not path is -d $dir + set -- dir (path dirname $dir) + end + else + if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 + string match -q -r -- '(?^[\s\S]*?(?=\n?$)$)' \ + (string replace -r -a -- '(?<=/)/|(?[\s\S]*)' $fzf_query + else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 + string match -q -r -- '^/?(?[\s\S]*?(?=\n?$)$)' \ + (string replace -- "$dir" '' $fzf_query | string collect -N) + else + set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N) + eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '') + end + end + end + + string escape -n -- "$dir" "$fzf_query" "$prefix" + end +#----END INCLUDE + + # Use complete builtin for specific commands + function __fzf_complete_native + set -l -- token (commandline -t) + set -l -- completions (eval complete -C \"$argv[1]\") + test -n "$completions"; or begin commandline -f repaint; return; end + + # Calculate tabstop based on longest completion item (sample first 500 for performance) + set -l -- tabstop 20 + set -l -- sample_size (math "min(500, "(count $completions)")") + for c in $completions[1..$sample_size] + set -l -- len (string length -V -- (string split -- \t $c)) + test -n "$len[2]" -a "$len[1]" -gt "$tabstop" + and set -- tabstop $len[1] + end + # limit to 120 to prevent long lines + set -- tabstop (math "min($tabstop + 4, 120)") + + set -l result + set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults \ + "--reverse --delimiter=\\t --nth=1 --tabstop=$tabstop --color=fg:dim,nth:regular" \ + $FZF_COMPLETION_OPTS $argv[2..-1] --accept-nth=1 --read0 --print0) + set -- result (string join0 -- $completions | eval (__fzfcmd) | string split0) + and begin + set -l -- tail ' ' + # Append / to bare ~username results (fish omits it unlike other shells) + set -- result (string replace -r -- '^(~\w+)\s?$' '$1/' $result) + # Don't add trailing space if single result is a directory + test (count $result) -eq 1 + and string match -q -- '*/' "$result"; and set -- tail '' + + set -l -- result (string escape -n -- $result) + + string match -q -- '~*' "$token" + and set result (string replace -r -- '^\\\\~' '~' $result) + + string match -q -- '$*' "$token" + and set result (string replace -r -- '^\\\\\$' '\$' $result) + + commandline -rt -- (string join ' ' -- $result)$tail + end + commandline -f repaint + end + + function _fzf_complete + set -l -- args (string escape -- $argv | string join ' ' | string split -- ' -- ') + set -l -- post_func (status function)_(string split -- ' ' $args[2])[1]_post + set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS $args[1]) + set -lx FZF_DEFAULT_OPTS_FILE + set -lx FZF_DEFAULT_COMMAND + set -l -- fzf_query (commandline -t | string escape) + set -l result + eval (__fzfcmd) --query=$fzf_query | while read -l r; set -a -- result $r; end + and if functions -q $post_func + commandline -rt -- (string collect -- $result | eval $post_func $args[2] | string join ' ')' ' + else + commandline -rt -- (string join -- ' ' (string escape -- $result))' ' + end + commandline -f repaint + end + + # Kill completion (process selection) + function _fzf_complete_kill + set -l -- fzf_query (commandline -t | string escape) + set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS \ + --accept-nth=2 -m --header-lines=1 --no-preview --wrap) + set -lx FZF_DEFAULT_OPTS_FILE + if type -q ps + set -l -- ps_cmd 'begin command ps -eo user,pid,ppid,start,time,command 2>/dev/null;' \ + 'or command ps -eo user,pid,ppid,time,args 2>/dev/null;' \ + 'or command ps --everyone --full --windows 2>/dev/null; end' + set -l -- result (eval $ps_cmd \| (__fzfcmd) --query=$fzf_query) + and commandline -rt -- (string join ' ' -- $result)" " + else + __fzf_complete_native "kill " --multi --query=$fzf_query + end + commandline -f repaint + end + + # Main completion function + function fzf-completion + set -l -- tokens (__fzf_cmd_tokens) + set -l -- current_token (commandline -t) + set -l -- cmd_name $tokens[1] + + # Route to appropriate completion function + if test -n "$tokens"; and functions -q _fzf_complete_$cmd_name + _fzf_complete_$cmd_name $tokens + else + set -l -- fzf_opt --query=$current_token --multi + __fzf_complete_native "$tokens $current_token" $fzf_opt + end + end + + # Bind Shift-Tab to fzf-completion (Tab retains native Fish behavior) + if test (string match -r -- '^\d+' $version) -ge 4 + bind shift-tab fzf-completion + bind -M insert shift-tab fzf-completion + else + bind -k btab fzf-completion + bind -M insert -k btab fzf-completion + end +end + +# Run setup +fzf_completion_setup diff --git a/shell/key-bindings.fish b/shell/key-bindings.fish index 2b44dfd0..ed0219b7 100644 --- a/shell/key-bindings.fish +++ b/shell/key-bindings.fish @@ -30,9 +30,12 @@ function fzf_key_bindings return 1 end +#----BEGIN INCLUDE common.fish +# NOTE: Do not directly edit this section, which is copied from "common.fish". +# To modify it, one can edit "common.fish" and run "./update.sh" to apply +# the changes. See code comments in "common.fish" for the implementation details. + function __fzf_defaults - # $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS - # $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40% string join ' ' -- \ "--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \ @@ -51,17 +54,51 @@ function fzf_key_bindings end end + function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes' + set -l tokens + if test (string match -r -- '^\d+' $version) -ge 4 + set -- tokens (commandline -xpc) + else + set -- tokens (commandline -opc) + end + + set -l -- var_count 0 + for i in $tokens + if string match -qr -- '^[\w]+=' $i + set var_count (math $var_count + 1) + else + break + end + end + set -e -- tokens[0..$var_count] + + while true + switch "$tokens[1]" + case builtin command + set -e -- tokens[1] + test "$tokens[1]" = "--"; and set -e -- tokens[1] + case env + set -e -- tokens[1] + test "$tokens[1]" = "--"; and set -e -- tokens[1] + while string match -qr -- '^[\w]+=' "$tokens[1]" + set -e -- tokens[1] + end + case '*' + break + end + end + + string escape -n -- $tokens + end + function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix' set -l fzf_query '' set -l prefix '' set -l dir '.' - # Set variables containing the major and minor fish version numbers, using - # a method compatible with all supported fish versions. set -l -- fish_major (string match -r -- '^\d+' $version) set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2] - # fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded. set -l -- match_regex '(?[\s\S]*?(?=\n?$)$)' set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S' if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3 @@ -69,16 +106,12 @@ function fzf_key_bindings set -- match_regex "(?$prefix_regex)?$match_regex" end - # Set $prefix and expanded $fzf_query with preserved trailing newlines. if test "$fish_major" -ge 4 - # fish v4.0.0 and newer string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N) else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 - # fish v3.2.0 - v3.7.1 (last v3) string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N) eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '') else - # fish older than v3.2.0 (v3.1b1 - v3.1.2) set -l -- cl_token (commandline --current-token --tokenize | string collect -N) set -- prefix (string match -r -- $prefix_regex $cl_token) set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N) @@ -86,22 +119,17 @@ function fzf_key_bindings end if test -n "$fzf_query" - # Normalize path in $fzf_query, set $dir to the longest existing directory. if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \) - # fish v3.5.0 and newer set -- fzf_query (path normalize -- $fzf_query) set -- dir $fzf_query while not path is -d $dir set -- dir (path dirname $dir) end else - # fish older than v3.5.0 (v3.1b1 - v3.4.1) if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 - # fish v3.2.0 - v3.4.1 string match -q -r -- '(?^[\s\S]*?(?=\n?$)$)' \ (string replace -r -a -- '(?<=/)/|(?[\s\S]*)' $fzf_query else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 - # fish v3.2.0 - v3.7.1 (last v3) string match -q -r -- '^/?(?[\s\S]*?(?=\n?$)$)' \ (string replace -- "$dir" '' $fzf_query | string collect -N) else - # fish older than v3.2.0 (v3.1b1 - v3.1.2) set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N) eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '') end @@ -130,6 +154,7 @@ function fzf_key_bindings string escape -n -- "$dir" "$fzf_query" "$prefix" end +#----END INCLUDE # Store current token in $dir as root for the 'find' command function fzf-file-widget -d "List files and folders" @@ -140,13 +165,13 @@ function fzf_key_bindings set -lx FZF_DEFAULT_OPTS (__fzf_defaults \ "--reverse --walker=file,dir,follow,hidden --scheme=path" \ - "$FZF_CTRL_T_OPTS --multi --print0") + "--multi $FZF_CTRL_T_OPTS --print0") set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND" set -lx FZF_DEFAULT_OPTS_FILE set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0) - and commandline -rt -- (string join -- ' ' $prefix(string escape -- $result))' ' + and commandline -rt -- (string join -- ' ' $prefix(string escape --no-quoted -- $result))' ' commandline -f repaint end @@ -234,3 +259,6 @@ function fzf_key_bindings end end + +# Run setup +fzf_key_bindings diff --git a/shell/update.sh b/shell/update.sh index 61374d35..4734e807 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -8,24 +8,26 @@ dir=${0%"${0##*/}"} update() { { - sed -n '1,/^#----BEGIN INCLUDE common\.sh/p' "$1" + sed -n "1,/^#----BEGIN INCLUDE $1/p" "$2" cat << EOF -# NOTE: Do not directly edit this section, which is copied from "common.sh". -# To modify it, one can edit "common.sh" and run "./update.sh" to apply -# the changes. See code comments in "common.sh" for the implementation details. +# NOTE: Do not directly edit this section, which is copied from "$1". +# To modify it, one can edit "$1" and run "./update.sh" to apply +# the changes. See code comments in "$1" for the implementation details. EOF echo - grep -v '^[[:blank:]]*#' "$dir/common.sh" # remove code comments in common.sh - sed -n '/^#----END INCLUDE/,$p' "$1" - } > "$1.part" + grep -v '^[[:blank:]]*#' "$dir/$1" # remove code comments from the common file + sed -n '/^#----END INCLUDE/,$p' "$2" + } > "$2.part" - mv -f "$1.part" "$1" + mv -f "$2.part" "$2" } -update "$dir/completion.bash" -update "$dir/completion.zsh" -update "$dir/key-bindings.bash" -update "$dir/key-bindings.zsh" +update "common.sh" "$dir/completion.bash" +update "common.sh" "$dir/completion.zsh" +update "common.sh" "$dir/key-bindings.bash" +update "common.sh" "$dir/key-bindings.zsh" +update "common.fish" "$dir/completion.fish" +update "common.fish" "$dir/key-bindings.fish" # Check if --check is in ARGV check=0 diff --git a/test/lib/common.fish b/test/lib/common.fish new file mode 100644 index 00000000..1192f8f1 --- /dev/null +++ b/test/lib/common.fish @@ -0,0 +1,17 @@ +# Unset fzf variables +set -e FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_DEFAULT_OPTS_FILE FZF_TMUX FZF_TMUX_OPTS +set -e FZF_CTRL_T_COMMAND FZF_CTRL_T_OPTS FZF_ALT_C_COMMAND FZF_ALT_C_OPTS FZF_CTRL_R_OPTS +set -e FZF_API_KEY +# Unset completion-specific variables +set -e FZF_COMPLETION_TRIGGER FZF_COMPLETION_OPTS + +set -gx FZF_DEFAULT_OPTS "--no-scrollbar --pointer '>' --marker '>'" +set -gx FZF_COMPLETION_TRIGGER '++' +set -gx fish_history fzf_test + +# Add fzf to PATH +fish_add_path <%= BASE %>/bin + +# Source key bindings and completion +source "<%= BASE %>/shell/key-bindings.fish" +source "<%= BASE %>/shell/completion.fish" diff --git a/test/lib/common.rb b/test/lib/common.rb index 0ddd3486..ef4dd915 100644 --- a/test/lib/common.rb +++ b/test/lib/common.rb @@ -11,6 +11,7 @@ require 'net/http' require 'json' TEMPLATE = File.read(File.expand_path('common.sh', __dir__)) +FISH_TEMPLATE = File.read(File.expand_path('common.fish', __dir__)) UNSETS = %w[ FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_TMUX FZF_TMUX_OPTS @@ -66,7 +67,16 @@ class Shell end def fish - "unset #{UNSETS.join(' ')}; rm -f ~/.local/share/fish/fzf_test_history; FZF_DEFAULT_OPTS=\"--no-scrollbar --pointer '>' --marker '>'\" fish_history=fzf_test fish" + @fish ||= + begin + confdir = '/tmp/fzf-fish' + FileUtils.rm_rf(confdir) + FileUtils.mkdir_p("#{confdir}/fish/conf.d") + File.open("#{confdir}/fish/conf.d/fzf.fish", 'w') do |f| + f.puts ERB.new(FISH_TEMPLATE).result(binding) + end + "rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish" + end end end end diff --git a/test/test_shell_integration.rb b/test/test_shell_integration.rb index fa379fcb..f51d7195 100644 --- a/test/test_shell_integration.rb +++ b/test/test_shell_integration.rb @@ -27,6 +27,10 @@ module TestShell tmux.prepare end + def trigger + '**' + end + def test_ctrl_t set_var('FZF_CTRL_T_COMMAND', 'seq 100') @@ -165,7 +169,11 @@ module CompletionTest FileUtils.touch(File.expand_path(f)) end tmux.prepare - tmux.send_keys 'cat /tmp/fzf-test/10**', :Tab + if shell == :fish + tmux.send_keys 'cat /tmp/fzf-test/10', 'C-t' + else + tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab + end tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.send_keys ' !d' tmux.until { |lines| assert_equal 2, lines.match_count } @@ -179,7 +187,11 @@ module CompletionTest # ~USERNAME** user = `whoami`.chomp tmux.send_keys 'C-u' - tmux.send_keys "cat ~#{user}**", :Tab + if shell == :fish + tmux.send_keys "cat ~#{user}", 'C-t' + else + tmux.send_keys "cat ~#{user}#{trigger}", :Tab + end tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.send_keys "/#{user}" tmux.until { |lines| assert(lines.any? { |l| l.end_with?("/#{user}") }) } @@ -190,14 +202,29 @@ module CompletionTest # ~INVALID_USERNAME** tmux.send_keys 'C-u' - tmux.send_keys 'cat ~such**', :Tab + if shell == :fish + tmux.send_keys 'cat ~such', 'C-t' + else + tmux.send_keys "cat ~such#{trigger}", :Tab + end tmux.until(true) { |lines| assert lines.any_include?('no~such~user') } tmux.send_keys :Enter - tmux.until(true) { |lines| assert_equal 'cat no~such~user', lines[-1] } + tmux.until(true) do |lines| + if shell == :fish + # Fish's string escape quotes filenames with ~ to prevent tilde expansion + assert_equal 'cat no\\~such\\~user', lines[-1] + else + assert_equal 'cat no~such~user', lines[-1] + end + end # /tmp/fzf\ test** tmux.send_keys 'C-u' - tmux.send_keys 'cat /tmp/fzf\ test/**', :Tab + if shell == :fish + tmux.send_keys 'cat /tmp/fzf\\ test/', 'C-t' + else + tmux.send_keys "cat /tmp/fzf\\ test/#{trigger}", :Tab + end tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.send_keys 'foobar$' tmux.until do |lines| @@ -210,7 +237,11 @@ module CompletionTest # Should include hidden files (1..100).each { |i| FileUtils.touch("/tmp/fzf-test/.hidden-#{i}") } tmux.send_keys 'C-u' - tmux.send_keys 'cat /tmp/fzf-test/hidden**', :Tab + if shell == :fish + tmux.send_keys 'cat /tmp/fzf-test/hidden', 'C-t' + else + tmux.send_keys "cat /tmp/fzf-test/hidden#{trigger}", :Tab + end tmux.until(true) do |lines| assert_equal 100, lines.match_count assert lines.any_include?('/tmp/fzf-test/.hidden-') @@ -223,70 +254,89 @@ module CompletionTest end def test_file_completion_root - tmux.send_keys 'ls /**', :Tab + if shell == :fish + tmux.send_keys 'ls /', 'C-t' + else + tmux.send_keys "ls /#{trigger}", :Tab + end tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.send_keys :Enter end def test_dir_completion + FileUtils.mkdir_p('/tmp/fzf-test-dir') (1..100).each do |idx| - FileUtils.mkdir_p("/tmp/fzf-test/d#{idx}") + FileUtils.mkdir_p("/tmp/fzf-test-dir/d#{idx}") end - FileUtils.touch('/tmp/fzf-test/d55/xxx') + FileUtils.touch('/tmp/fzf-test-dir/d55/xxx') tmux.prepare - tmux.send_keys 'cd /tmp/fzf-test/**', :Tab + if shell == :fish + tmux.send_keys 'cd /tmp/fzf-test-dir/', 'C-t' + else + tmux.send_keys "cd /tmp/fzf-test-dir/#{trigger}", :Tab + end tmux.until { |lines| assert_operator lines.match_count, :>, 0 } - tmux.send_keys :Tab, :Tab # Tab does not work here - tmux.send_keys 55 + # Tab selects items in C-t's --multi mode, so skip for fish + tmux.send_keys :Tab, :Tab unless shell == :fish # Tab does not work here + tmux.send_keys '55/$' tmux.until do |lines| assert_equal 1, lines.match_count - assert_includes lines, '> 55' - assert_includes lines, '> /tmp/fzf-test/d55/' + assert_includes lines, '> 55/$' + assert_includes lines, '> /tmp/fzf-test-dir/d55/' end tmux.send_keys :Enter - tmux.until(true) { |lines| assert_equal 'cd /tmp/fzf-test/d55/', lines[-1] } + tmux.until(true) { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/', lines[-1] } + # C-t appends a trailing space after the result + tmux.send_keys :BSpace if shell == :fish tmux.send_keys :xx - tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/xx', lines[-1] } + tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/xx', lines[-1] } # Should not match regular files (bash-only) if instance_of?(TestBash) tmux.send_keys :Tab - tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/xx', lines[-1] } + tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/xx', lines[-1] } end # Fail back to plusdirs tmux.send_keys :BSpace, :BSpace, :BSpace - tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55', lines[-1] } + tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55', lines[-1] } tmux.send_keys :Tab - tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/', lines[-1] } + tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/', lines[-1] } + ensure + FileUtils.rm_rf('/tmp/fzf-test-dir') end def test_process_completion - tmux.send_keys 'sleep 12345 &', :Enter - lines = tmux.until { |lines| assert lines[-1]&.start_with?('[1] ') } - pid = lines[-1]&.split&.last - tmux.prepare - tmux.send_keys 'C-L' - tmux.send_keys 'kill **', :Tab - tmux.until { |lines| assert_operator lines.match_count, :>, 0 } - tmux.send_keys 'sleep12345' - tmux.until { |lines| assert lines.any_include?('sleep 12345') } - tmux.send_keys :Enter - tmux.until(true) { |lines| assert_equal "kill #{pid}", lines[-1] } - ensure - if pid - begin - Process.kill('KILL', pid.to_i) - rescue StandardError - nil + skip('fish background job format differs') if shell == :fish + + begin + tmux.send_keys 'sleep 12345 &', :Enter + lines = tmux.until { |lines| assert lines[-1]&.start_with?('[1] ') } + pid = lines[-1]&.split&.last + tmux.prepare + tmux.send_keys 'C-L' + tmux.send_keys "kill #{trigger}", :Tab + tmux.until { |lines| assert_operator lines.match_count, :>, 0 } + tmux.send_keys 'sleep12345' + tmux.until { |lines| assert lines.any_include?('sleep 12345') } + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal "kill #{pid}", lines[-1] } + ensure + if pid + begin + Process.kill('KILL', pid.to_i) + rescue StandardError + nil + end end end end def test_custom_completion + skip('fish does not use _fzf_compgen_path; path completion is via ctrl-t') if shell == :fish tmux.send_keys '_fzf_compgen_path() { echo "$1"; seq 10; }', :Enter tmux.prepare - tmux.send_keys 'ls /tmp/**', :Tab + tmux.send_keys "ls /tmp/#{trigger}", :Tab tmux.until { |lines| assert_equal 11, lines.match_count } tmux.send_keys :Tab, :Tab, :Tab tmux.until { |lines| assert_equal 3, lines.select_count } @@ -295,11 +345,12 @@ module CompletionTest end def test_unset_completion + skip('fish has native completion for set and unset variables') if shell == :fish tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter tmux.prepare # Using tmux - tmux.send_keys 'unset FZFFOOBR**', :Tab + tmux.send_keys "unset FZFFOOBR#{trigger}", :Tab tmux.until { |lines| assert_equal 1, lines.match_count } tmux.send_keys :Enter tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] } @@ -308,34 +359,57 @@ module CompletionTest # FZF_TMUX=1 new_shell tmux.focus - tmux.send_keys 'unset FZFFOOBR**', :Tab + tmux.send_keys "unset FZFFOOBR#{trigger}", :Tab tmux.until { |lines| assert_equal 1, lines.match_count } tmux.send_keys :Enter tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] } end def test_completion_in_command_sequence - tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter - tmux.prepare - - triggers = ['**', '~~', '++', 'ff', '/'] - triggers.push('&', '[', ';', '`') if instance_of?(TestZsh) - - triggers.each do |trigger| - set_var('FZF_COMPLETION_TRIGGER', trigger) - command = "echo foo; QUX=THUD unset FZFFOOBR#{trigger}" - tmux.send_keys command.sub(/(;|`)$/, '\\\\\1'), :Tab + if shell == :fish + FileUtils.mkdir_p('/tmp/fzf-test-seq') + FileUtils.touch('/tmp/fzf-test-seq/fzffoobar') + tmux.prepare + # Fish uses Shift-Tab for fzf completion (no trigger system) + command = 'echo foo; QUX=THUD ls /tmp/fzf-test-seq/fzffoobr' + expected = 'echo foo; QUX=THUD ls /tmp/fzf-test-seq/fzffoobar' + tmux.send_keys command, :BTab tmux.until { |lines| assert_equal 1, lines.match_count } tmux.send_keys :Enter - tmux.until { |lines| assert_equal 'echo foo; QUX=THUD unset FZFFOOBAR', lines[-1] } + tmux.until { |lines| assert_equal expected, lines[-1] } + else + tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter + tmux.prepare + + triggers = ['**', '~~', '++', 'ff', '/'] + triggers.push('&', '[', ';', '`') if instance_of?(TestZsh) + + triggers.each do |trigger| + set_var('FZF_COMPLETION_TRIGGER', trigger) + command = "echo foo; QUX=THUD unset FZFFOOBR#{trigger}" + expected = 'echo foo; QUX=THUD unset FZFFOOBAR' + tmux.send_keys command.sub(/(;|`)$/, '\\\\\1'), :Tab + tmux.until { |lines| assert_equal 1, lines.match_count } + tmux.send_keys :Enter + tmux.until { |lines| assert_equal expected, lines[-1] } + end end + ensure + FileUtils.rm_rf('/tmp/fzf-test-seq') if shell == :fish end def test_file_completion_unicode FileUtils.mkdir_p('/tmp/fzf-test') - tmux.paste "cd /tmp/fzf-test; echo test3 > $'fzf-unicode \\355\\205\\214\\354\\212\\244\\355\\212\\2701'; echo test4 > $'fzf-unicode \\355\\205\\214\\354\\212\\244\\355\\212\\2702'" + # Shell-agnostic file creation + File.write('/tmp/fzf-test/fzf-unicode 테스트1', "test3\n") + File.write('/tmp/fzf-test/fzf-unicode 테스트2', "test4\n") + tmux.send_keys 'cd /tmp/fzf-test', :Enter tmux.prepare - tmux.send_keys 'cat fzf-unicode**', :Tab + if shell == :fish + tmux.send_keys 'cat fzf-unicode', 'C-t' + else + tmux.send_keys "cat fzf-unicode#{trigger}", :Tab + end tmux.until { |lines| assert_equal 2, lines.match_count } tmux.send_keys '1' @@ -358,36 +432,41 @@ module CompletionTest end def test_custom_completion_api - tmux.send_keys 'eval "_fzf$(declare -f _comprun)"', :Enter - %w[f g].each do |command| - tmux.prepare - tmux.send_keys "#{command} b**", :Tab - tmux.until do |lines| - assert_equal 2, lines.item_count - assert_equal 1, lines.match_count - assert lines.any_include?("prompt-#{command}") - assert lines.any_include?("preview-#{command}-bar") + skip('bash-specific _comprun/declare syntax') if shell == :fish + + begin + tmux.send_keys 'eval "_fzf$(declare -f _comprun)"', :Enter + %w[f g].each do |command| + tmux.prepare + tmux.send_keys "#{command} b#{trigger}", :Tab + tmux.until do |lines| + assert_equal 2, lines.item_count + assert_equal 1, lines.match_count + assert lines.any_include?("prompt-#{command}") + assert lines.any_include?("preview-#{command}-bar") + end + tmux.send_keys :Enter + tmux.until { |lines| assert_equal "#{command} #{command}barbar", lines[-1] } + tmux.send_keys 'C-u' end - tmux.send_keys :Enter - tmux.until { |lines| assert_equal "#{command} #{command}barbar", lines[-1] } - tmux.send_keys 'C-u' + ensure + tmux.prepare + tmux.send_keys 'unset -f _fzf_comprun', :Enter end - ensure - tmux.prepare - tmux.send_keys 'unset -f _fzf_comprun', :Enter end def test_ssh_completion + skip('fish uses native ssh completion') if shell == :fish (1..5).each { |i| FileUtils.touch("/tmp/fzf-test-ssh-#{i}") } - tmux.send_keys 'ssh jg@localhost**', :Tab + tmux.send_keys "ssh jg@localhost#{trigger}", :Tab tmux.until do |lines| assert_operator lines.match_count, :>=, 1 end tmux.send_keys :Enter tmux.until { |lines| assert lines.any_include?('ssh jg@localhost') } - tmux.send_keys ' -i /tmp/fzf-test-ssh**', :Tab + tmux.send_keys " -i /tmp/fzf-test-ssh#{trigger}", :Tab tmux.until do |lines| assert_operator lines.match_count, :>=, 5 assert_equal 0, lines.select_count @@ -399,11 +478,344 @@ module CompletionTest tmux.send_keys :Enter tmux.until { |lines| assert lines.any_include?('ssh jg@localhost -i /tmp/fzf-test-ssh-') } - tmux.send_keys 'localhost**', :Tab + tmux.send_keys "localhost#{trigger}", :Tab tmux.until do |lines| assert_operator lines.match_count, :>=, 1 end end + + def test_option_equals_long_option + FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-long') + FileUtils.touch('/tmp/fzf-test-opt-eq-long/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-eq-long', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command --opt=SECURI', 'C-t' + else + tmux.send_keys "some-command --opt=SECURI#{trigger}", :Tab + end + + case shell + when :bash + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> SECURI' + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'some-command --opt=SECURITY.md', lines[-1] } + when :fish + tmux.until do |lines| + assert_equal 1, lines.match_count + assert lines.any_include?('SECURITY.md') + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'some-command --opt=SECURITY.md', lines[-1] } + when :zsh + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> --opt=SECURI' + end + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-eq-long') + end + + def test_option_equals_long_option_after_double_dash + FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-long-ddash') + FileUtils.touch('/tmp/fzf-test-opt-eq-long-ddash/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-eq-long-ddash', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command -- --opt=SECURI', 'C-t' + else + tmux.send_keys "some-command -- --opt=SECURI#{trigger}", :Tab + end + + case shell + when :bash + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> SECURI' + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'some-command -- --opt=SECURITY.md', lines[-1] } + when :fish, :zsh + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> --opt=SECURI' + end + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-eq-long-ddash') + end + + def test_option_equals_short_option + FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-short') + FileUtils.touch('/tmp/fzf-test-opt-eq-short/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-eq-short', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command -o=SECURI', 'C-t' + else + tmux.send_keys "some-command -o=SECURI#{trigger}", :Tab + end + + case shell + when :bash, :fish + tmux.until do |lines| + assert_equal 1, lines.match_count + assert lines.any_include?('> SECURITY.md') + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'some-command -o=SECURITY.md', lines[-1] } + when :zsh + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> -o=SECURI' + end + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-eq-short') + end + + def test_option_equals_short_option_after_double_dash + FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-short-ddash') + FileUtils.touch('/tmp/fzf-test-opt-eq-short-ddash/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-eq-short-ddash', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command -- -o=SECURI', 'C-t' + else + tmux.send_keys "some-command -- -o=SECURI#{trigger}", :Tab + end + + case shell + when :bash + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> SECURITY.md' + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'some-command -- -o=SECURITY.md', lines[-1] } + when :fish, :zsh + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> -o=SECURI' + end + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-eq-short-ddash') + end + + def test_option_no_equals_long_option + FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-long') + FileUtils.touch('/tmp/fzf-test-opt-no-eq-long/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-long', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command --optSECURI', 'C-t' + else + tmux.send_keys "some-command --optSECURI#{trigger}", :Tab + end + + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> --optSECURI' + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-long') + end + + def test_option_no_equals_long_option_after_double_dash + FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-long-ddash') + FileUtils.touch('/tmp/fzf-test-opt-no-eq-long-ddash/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-long-ddash', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command -- --optSECURI', 'C-t' + else + tmux.send_keys "some-command -- --optSECURI#{trigger}", :Tab + end + + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> --optSECURI' + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-long-ddash') + end + + def test_option_no_equals_short_option + FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-short') + FileUtils.touch('/tmp/fzf-test-opt-no-eq-short/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-short', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command -oSECURI', 'C-t' + else + tmux.send_keys "some-command -oSECURI#{trigger}", :Tab + end + + case shell + when :bash, :zsh + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> -oSECURI' + end + when :fish + tmux.until do |lines| + assert_equal 1, lines.match_count + assert lines.any_include?('> SECURITY.md') + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'some-command -oSECURITY.md', lines[-1] } + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-short') + end + + def test_option_no_equals_short_option_after_double_dash + FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-short-ddash') + FileUtils.touch('/tmp/fzf-test-opt-no-eq-short-ddash/SECURITY.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-short-ddash', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'some-command -- -oSECURI', 'C-t' + else + tmux.send_keys "some-command -- -oSECURI#{trigger}", :Tab + end + + tmux.until do |lines| + assert_equal 0, lines.match_count + assert_includes lines, '> -oSECURI' + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-short-ddash') + end + + def test_filename_with_newline + FileUtils.mkdir_p('/tmp/fzf-test-newline') + FileUtils.touch("/tmp/fzf-test-newline/xyz\nwith\nnewlines") + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-newline', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'cat xyz', 'C-t' + else + tmux.send_keys "cat xyz#{trigger}", :Tab + end + + case shell + when :fish + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> xyz' + end + tmux.send_keys :Enter + # fish escapes newlines in filenames + tmux.until(true) { |lines| assert_equal 'cat xyz\\nwith\\nnewlines', lines[-1] } + when :bash, :zsh + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> xyz' + end + tmux.send_keys :Enter + # bash and zsh replace newlines with spaces in filenames + tmux.until(true) { |lines| assert_equal 'cat xyz with newlines', lines[-1] } + end + ensure + FileUtils.rm_rf('/tmp/fzf-test-newline') + end + + def test_path_with_special_chars + FileUtils.mkdir_p('/tmp/fzf-test-[special]') + FileUtils.touch('/tmp/fzf-test-[special]/xyz123') + tmux.prepare + if shell == :fish + tmux.send_keys 'ls /tmp/fzf-test-\[special\]/xyz', 'C-t' + else + tmux.send_keys "ls /tmp/fzf-test-\\[special\\]/xyz#{trigger}", :Tab + end + tmux.until { |lines| assert_equal 1, lines.match_count } + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'ls /tmp/fzf-test-\\[special\\]/xyz123', lines[-1] } + ensure + FileUtils.rm_rf('/tmp/fzf-test-[special]') + end + + def test_query_with_dollar_anchor + FileUtils.mkdir_p('/tmp/fzf-test-dollar-anchor') + FileUtils.touch('/tmp/fzf-test-dollar-anchor/file.txt') + FileUtils.touch('/tmp/fzf-test-dollar-anchor/filetxt.md') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-dollar-anchor', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'ls txt$', 'C-t' + else + tmux.send_keys "ls txt$#{trigger}", :Tab + end + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> txt$' + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'ls file.txt', lines[-1] } + ensure + FileUtils.rm_rf('/tmp/fzf-test-dollar-anchor') + end + + def test_single_flag_completion + FileUtils.mkdir_p('/tmp/fzf-test-single-flag') + FileUtils.touch('/tmp/fzf-test-single-flag/-testfile.txt') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-single-flag', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'ls -', 'C-t' + else + tmux.send_keys "ls -#{trigger}", :Tab + end + + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> -' + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'ls -testfile.txt', lines[-1] } + ensure + FileUtils.rm_rf('/tmp/fzf-test-single-flag') + end + + def test_double_flag_completion + FileUtils.mkdir_p('/tmp/fzf-test-double-flag') + FileUtils.touch('/tmp/fzf-test-double-flag/--testfile.txt') + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test-double-flag', :Enter + tmux.prepare + if shell == :fish + tmux.send_keys 'ls --', 'C-t' + else + tmux.send_keys "ls --#{trigger}", :Tab + end + + tmux.until do |lines| + assert_equal 1, lines.match_count + assert_includes lines, '> --' + end + tmux.send_keys :Enter + tmux.until(true) { |lines| assert_equal 'ls --testfile.txt', lines[-1] } + ensure + FileUtils.rm_rf('/tmp/fzf-test-double-flag') + end end class TestBash < TestBase @@ -424,7 +836,7 @@ class TestBash < TestBase tmux.paste 'touch /tmp/foo; _fzf_completion_loader=1' tmux.paste '_completion_loader() { complete -o default fake; }' tmux.paste 'complete -F _fzf_path_completion -o default -o bashdefault fake' - tmux.send_keys 'fake /tmp/foo**', :Tab + tmux.send_keys "fake /tmp/foo#{trigger}", :Tab tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.send_keys 'C-c' @@ -433,7 +845,7 @@ class TestBash < TestBase tmux.send_keys :Tab, 'C-u' tmux.prepare - tmux.send_keys 'fake /tmp/foo**', :Tab + tmux.send_keys "fake /tmp/foo#{trigger}", :Tab tmux.until { |lines| assert_operator lines.match_count, :>, 0 } end end @@ -455,7 +867,7 @@ class TestZsh < TestBase tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter ['unset', '\unset', "'unset'"].each do |command| tmux.prepare - tmux.send_keys "#{command} FZFFOOBR**", :Tab + tmux.send_keys "#{command} FZFFOOBR#{trigger}", :Tab tmux.until { |lines| assert_equal 1, lines.match_count } tmux.send_keys :Enter tmux.until { |lines| assert_equal "#{command} FZFFOOBAR", lines[-1] } @@ -579,13 +991,18 @@ end class TestFish < TestBase include TestShell + include CompletionTest def shell :fish end + def trigger + '++' + end + def new_shell - tmux.send_keys 'env FZF_TMUX=1 FZF_DEFAULT_OPTS=--no-scrollbar fish', :Enter + tmux.send_keys 'env FZF_TMUX=1 XDG_CONFIG_HOME=/tmp/fzf-fish fish', :Enter tmux.send_keys 'function fish_prompt; end; clear', :Enter tmux.until { |lines| assert_empty lines } end diff --git a/uninstall b/uninstall index 76950ef1..7882cf5c 100755 --- a/uninstall +++ b/uninstall @@ -33,6 +33,9 @@ for opt in "$@"; do esac done +cd "$(dirname "${BASH_SOURCE[0]}")" +fzf_base=$(pwd) + ask() { while true; do read -p "$1 ([y]/n) " -r @@ -94,12 +97,15 @@ done bind_file="${fish_dir}/functions/fish_user_key_bindings.fish" if [ -f "$bind_file" ]; then remove_line "$bind_file" "fzf_key_bindings" + remove_line "$bind_file" "fzf_completion_setup" remove_line "$bind_file" "fzf --fish | source" fi if [ -d "${fish_dir}/functions" ]; then remove "${fish_dir}/functions/fzf.fish" remove "${fish_dir}/functions/fzf_key_bindings.fish" + remove_line "$bind_file" "source \"${fzf_base}/shell/completion.fish\"" + remove_line "$bind_file" "source \"${fzf_base}/shell/key-bindings.fish\"" if [ -z "$(ls -A "${fish_dir}/functions")" ]; then rmdir "${fish_dir}/functions"