From 8c8b21ae52306cab5cece0095802ae15d0b8e3f4 Mon Sep 17 00:00:00 2001 From: oddlama Date: Mon, 13 Feb 2023 01:49:03 +0100 Subject: [PATCH 1/5] feat: proper fzf tab integration - don't add spaces after completing directories - don't add space after arguments like --color= - add -- (end of options) before completing filenames starting with - - proper initial query with prefix detection - omit common path prefix when completing paths --- functions/_fzf_complete.fish | 178 ++++++++++++++++++++++++++ functions/fzf_configure_bindings.fish | 4 +- 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 functions/_fzf_complete.fish diff --git a/functions/_fzf_complete.fish b/functions/_fzf_complete.fish new file mode 100644 index 00000000..c462e5d6 --- /dev/null +++ b/functions/_fzf_complete.fish @@ -0,0 +1,178 @@ +# External limitations: +# - fish-shell/fish-shell#9577: folders ending in '=' break fish's internal path completion +# (It thinks that it has to complete from / because `a_folder=/` looks like an argument to fish. +# - fish cannot give reliable context information on completions. Knowing whether a completion +# is a file or argument can only be determined via a heuristic. +set --global _fzf_complete_min_description_offset 14 +function _fzf_complete --description "Shell completion using fzf" + # Produce a list of completions from which to choose + # and do nothing if there is nothing to select from + set -l cmd (commandline -co) (commandline -t) + set -l completions (complete --escape --do-complete "$cmd") + test (count $completions) -eq 0; and return + + set -l results + set -l first_completion (printf "%s" $completions[1] | cut -f1 --zero-terminated | string split0) + + # A prefix that will be prepended before each selected completion + set -l each_prefix "" + + # Only skip fzf if there is a single completion and it starts with the expected completion prefix. + # Otherwise, the completion originates from a match in the description which the user might + # want to review first (e.g. man ima might match `feh`, an image viewer) + if test (count $completions) -eq 1; and test (string sub -l (string length -- (commandline -ct)) -- $first_completion) = (commandline -ct) + # If there is only one option then we don't need fzf + # Everytime we need to cut, we need zero termination to + # avoid newlines from being interpreted later. + set results $first_completion + else + # We preprocess the whole list to prevent spawning a slow cut process for each completion. + # From here on, make sure to use -- and zero termination everywhere to prevent introducing + # new edge-cases where characters are interpreted wrongly + set -l actual_completions (string join0 -- $completions | cut -f1 --zero-terminated | string split0) + set -l descriptions (string join0 -- $completions | cut -f2- --zero-terminated | string split0) + + # Find the common prefix of all completions. Yes this seems to be a hard + # thing to do in fish if it should be fast. No external process may be + # spawned as this is too slow. So fish internals it is :/ + set -l common_prefix $completions[1] + set -l common_prefix_length (string length -- $common_prefix) + for comp in $actual_completions[2..] + if test (string sub -l $common_prefix_length -- $comp) != $common_prefix + set -l new_common_prefix_length 0 + set -l try_common_prefix_length (math min $common_prefix_length,(string length -- $comp)) + # Binary search for new common prefix + set -l step $try_common_prefix_length + while test $step != 0 + set step (math --scale 0 $step / 2) + # Adjust range to the left or right depending on whether the current new prefix matches + set -l xs (string sub -l $try_common_prefix_length -- $comp $common_prefix) + set -l op (test $xs[1] = $xs[2]; and printf "+"; and set new_common_prefix_length $try_common_prefix_length; or printf "-") + set try_common_prefix_length (math --scale 0 $try_common_prefix_length $op (math max 1,$step)) + end + set common_prefix_length $new_common_prefix_length + set common_prefix (string sub -l $new_common_prefix_length -- $common_prefix) + # Stop if there is no common prefix + test $common_prefix_length = 0; and break + end + end + + # If the common prefix includes a / we are completing a file path. + # Strip the prefix until the last / completely and later re-add it on the replaced token + set -l path_prefix (string match --regex --groups-only -- '^(.*/)[^/]*$' $common_prefix) + if test $status = 0 + set -l path_prefix_length (string length -- $path_prefix) + set -l new_start (math 1 + $path_prefix_length) + for i in (seq (count $completions)) + set completions[$i] (string sub -s $new_start -- $completions[$i]) + set actual_completions[$i] (string sub -s $new_start -- $actual_completions[$i]) + end + + # We have a path-like prefix and will therefore strip this common prefix from all + # completions to un-clutter the menu. + set each_prefix (string sub -l $path_prefix_length -- $common_prefix) + set common_prefix_length (math $common_prefix_length - $path_prefix_length) + set common_prefix (string sub -s $new_start -- $common_prefix) + end + + # Detect whether descriptions are present and the length of each completion. + set -l has_descriptions false + set -l longest_completion $_fzf_complete_min_description_offset + for i in (seq (count $completions)) + if string match --quiet -- "*"\t"*" $completions[$i] + set has_descriptions true + + # Here we additionally remember the longest completion to align the descriptions in fzf later + set longest_completion (math max $longest_completion,(string length -- $actual_completions[$i])) + set completions[$i] $actual_completions[$i]\t(set_color -i yellow)$descriptions[$i](set_color normal) + end + end + + # FIXME Would technically work, but not sure it's worth the effort to match the descriptions + # after filtering. + # Remove tokens from the completion list that are already present on the current commandline. + #set -l all_tokens (commandline -o) + #set -l current_token_index (math 1 + (count (commandline -co))) + #set --erase all_tokens[$current_token_index] + #set -l remaining (comm --zero-terminated -23 (string join0 -- $actual_completions | psub) (string join0 -- $all_tokens | sort | psub) | string split0) + + # TODO pressing / in a completion should add the completion and immediately start a new completion + test $has_descriptions = true; and set -l fzf_complete_description_opts \ + --tabstop=(math 2 + $longest_completion) + set -l fzf_output ( + string join0 -- $completions \ + | _fzf_wrapper \ + --read0 \ + --print0 \ + --ansi \ + --multi \ + --bind=tab:down,btab:up,change:top,ctrl-space:toggle \ + --tiebreak=begin \ + --query=$common_prefix \ + --print-query \ + $fzf_complete_description_opts \ + $fzf_complete_opts \ + | cut -f1 --zero-terminated \ + | string split0) + + switch $pipestatus[2] + case 0 + # If something was selected, discard the current query + set results $fzf_output[2..-1] + case 1 + # User accepted without selecting anything, thus we will + # use just the current query + set results $fzf_output[1] + case '*' + # Fzf failed, do nothing. + commandline -f repaint + return + end + end + + set -l prefix "" + set -l suffix " " + # By default we want to append a space to completed options. + # While this is always true when competing multiple things, there + # are some cases in which we don't want to add a space: + if test (count $results) -eq 1 + # When a completion ends in a / we usually want to keep adding to that completion. + # This may be because it is a directory (or link to a directory), or just + # an option that takes a category/name tuple like `emerge app-shells/fish`. + # Also, completions like ~something are directories. + # + # In a similar manner, completions ending in = are usually part of an argument + # that expects a parameter (like --color= or dd if=). The same logic applies here. + set -l first_char (string sub -l 1 -- $results[1]) + set -l last_char (string sub -s -1 -- $results[1]) + if test $last_char = =; or test $last_char = / + set suffix "" + else if string match --regex --quiet -- '^~[^/]*$' $results[1] + set suffix "/" + end + end + + if not contains -- "--" (commandline -co) + # If a path is being completed and it starts with --, we add -- to terminate argument interpreting. + # Technically not always correct (the program may not accept --), but the alternative is worse + # and this will make the user notice it. + for r in $results + test - = (string sub -l 1 -- $r); and test -e $r; and set prefix "-- "; and break + end + end + + # If each_prefix is set, we need to apply it to each result + # before replacing the token + if test -n $each_prefix + echo $each_prefix + set -l new_tokens + for r in $results + set -a new_tokens $each_prefix$r + end + commandline -t -- "$prefix$new_tokens$suffix" + else + commandline -t -- "$prefix$results$suffix" + end + + commandline -f repaint +end diff --git a/functions/fzf_configure_bindings.fish b/functions/fzf_configure_bindings.fish index ec24f73d..c1ad2b0c 100644 --- a/functions/fzf_configure_bindings.fish +++ b/functions/fzf_configure_bindings.fish @@ -16,13 +16,14 @@ function fzf_configure_bindings --description "Installs the default key bindings else # Initialize with default key sequences and then override or disable them based on flags # index 1 = directory, 2 = git_log, 3 = git_status, 4 = history, 5 = processes, 6 = variables - set key_sequences \e\cf \e\cl \e\cs \cr \e\cp \cv # \c = control, \e = escape + set key_sequences \e\cf \e\cl \e\cs \cr \e\cp \cv \t # \c = control, \e = escape set --query _flag_directory && set key_sequences[1] "$_flag_directory" set --query _flag_git_log && set key_sequences[2] "$_flag_git_log" set --query _flag_git_status && set key_sequences[3] "$_flag_git_status" set --query _flag_history && set key_sequences[4] "$_flag_history" set --query _flag_processes && set key_sequences[5] "$_flag_processes" set --query _flag_variables && set key_sequences[6] "$_flag_variables" + set --query _flag_complete && set key_sequences[7] "$_flag_complete" # If fzf bindings already exists, uninstall it first for a clean slate if functions --query _fzf_uninstall_bindings @@ -36,6 +37,7 @@ function fzf_configure_bindings --description "Installs the default key bindings test -n $key_sequences[4] && bind --mode $mode $key_sequences[4] _fzf_search_history test -n $key_sequences[5] && bind --mode $mode $key_sequences[5] _fzf_search_processes test -n $key_sequences[6] && bind --mode $mode $key_sequences[6] "$_fzf_search_vars_command" + test -n $key_sequences[7] && bind --mode $mode $key_sequences[7] _fzf_complete end function _fzf_uninstall_bindings --inherit-variable key_sequences From 2d6e6daf528ec20335fac065ffd29d7e4f2f4039 Mon Sep 17 00:00:00 2001 From: oddlama Date: Sat, 25 Feb 2023 20:09:16 +0100 Subject: [PATCH 2/5] fix: adhere to naming convention and add option_spec --- completions/fzf_configure_bindings.fish | 1 + .../{_fzf_complete.fish => _fzf_search_completions.fish} | 2 +- functions/fzf_configure_bindings.fish | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) rename functions/{_fzf_complete.fish => _fzf_search_completions.fish} (99%) diff --git a/completions/fzf_configure_bindings.fish b/completions/fzf_configure_bindings.fish index ec7db935..fe4c2a3b 100644 --- a/completions/fzf_configure_bindings.fish +++ b/completions/fzf_configure_bindings.fish @@ -6,3 +6,4 @@ complete fzf_configure_bindings --long git_status --description "Change the key complete fzf_configure_bindings --long history --description "Change the key binding for searching history" complete fzf_configure_bindings --long processes --description "Change the key binding for searching processes" complete fzf_configure_bindings --long variables --description "Change the key binding for searching variables" +complete fzf_configure_bindings --long completions --description "Change the key binding for searching completions" diff --git a/functions/_fzf_complete.fish b/functions/_fzf_search_completions.fish similarity index 99% rename from functions/_fzf_complete.fish rename to functions/_fzf_search_completions.fish index c462e5d6..1f3847f1 100644 --- a/functions/_fzf_complete.fish +++ b/functions/_fzf_search_completions.fish @@ -4,7 +4,7 @@ # - fish cannot give reliable context information on completions. Knowing whether a completion # is a file or argument can only be determined via a heuristic. set --global _fzf_complete_min_description_offset 14 -function _fzf_complete --description "Shell completion using fzf" +function _fzf_search_completions --description "Shell completion using fzf" # Produce a list of completions from which to choose # and do nothing if there is nothing to select from set -l cmd (commandline -co) (commandline -t) diff --git a/functions/fzf_configure_bindings.fish b/functions/fzf_configure_bindings.fish index c1ad2b0c..74715d57 100644 --- a/functions/fzf_configure_bindings.fish +++ b/functions/fzf_configure_bindings.fish @@ -4,7 +4,7 @@ function fzf_configure_bindings --description "Installs the default key bindings # no need to install bindings if not in interactive mode or running tests status is-interactive || test "$CI" = true; or return - set options_spec h/help 'directory=?' 'git_log=?' 'git_status=?' 'history=?' 'processes=?' 'variables=?' + set options_spec h/help 'directory=?' 'git_log=?' 'git_status=?' 'history=?' 'processes=?' 'variables=?' 'completions=?' argparse --max-args=0 --ignore-unknown $options_spec -- $argv 2>/dev/null if test $status -ne 0 echo "Invalid option or a positional argument was provided." >&2 @@ -23,7 +23,7 @@ function fzf_configure_bindings --description "Installs the default key bindings set --query _flag_history && set key_sequences[4] "$_flag_history" set --query _flag_processes && set key_sequences[5] "$_flag_processes" set --query _flag_variables && set key_sequences[6] "$_flag_variables" - set --query _flag_complete && set key_sequences[7] "$_flag_complete" + set --query _flag_completions && set key_sequences[7] "$_flag_completions" # If fzf bindings already exists, uninstall it first for a clean slate if functions --query _fzf_uninstall_bindings @@ -37,7 +37,7 @@ function fzf_configure_bindings --description "Installs the default key bindings test -n $key_sequences[4] && bind --mode $mode $key_sequences[4] _fzf_search_history test -n $key_sequences[5] && bind --mode $mode $key_sequences[5] _fzf_search_processes test -n $key_sequences[6] && bind --mode $mode $key_sequences[6] "$_fzf_search_vars_command" - test -n $key_sequences[7] && bind --mode $mode $key_sequences[7] _fzf_complete + test -n $key_sequences[7] && bind --mode $mode $key_sequences[7] _fzf_search_completions end function _fzf_uninstall_bindings --inherit-variable key_sequences From 8213717ee093e7ff4ff4d51755876e01d0d6386e Mon Sep 17 00:00:00 2001 From: oddlama Date: Sun, 5 Mar 2023 01:01:06 +0100 Subject: [PATCH 3/5] feat: remove cut dependency --- functions/_fzf_search_completions.fish | 33 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/functions/_fzf_search_completions.fish b/functions/_fzf_search_completions.fish index 1f3847f1..3954dc58 100644 --- a/functions/_fzf_search_completions.fish +++ b/functions/_fzf_search_completions.fish @@ -3,7 +3,10 @@ # (It thinks that it has to complete from / because `a_folder=/` looks like an argument to fish. # - fish cannot give reliable context information on completions. Knowing whether a completion # is a file or argument can only be determined via a heuristic. -set --global _fzf_complete_min_description_offset 14 +# - Completion is slow. Not because this script is slow but because fish's `complete` command +# can take several seconds even for just 100 entries. Just run `time complete --escape --do-complete l` +# to see for yourself. +set --global _fzf_search_completions_min_description_offset 14 function _fzf_search_completions --description "Shell completion using fzf" # Produce a list of completions from which to choose # and do nothing if there is nothing to select from @@ -12,7 +15,7 @@ function _fzf_search_completions --description "Shell completion using fzf" test (count $completions) -eq 0; and return set -l results - set -l first_completion (printf "%s" $completions[1] | cut -f1 --zero-terminated | string split0) + set -l first_completion (string split --fields 1 --max 1 --right \t -- $completions[1]) # A prefix that will be prepended before each selected completion set -l each_prefix "" @@ -22,15 +25,20 @@ function _fzf_search_completions --description "Shell completion using fzf" # want to review first (e.g. man ima might match `feh`, an image viewer) if test (count $completions) -eq 1; and test (string sub -l (string length -- (commandline -ct)) -- $first_completion) = (commandline -ct) # If there is only one option then we don't need fzf - # Everytime we need to cut, we need zero termination to - # avoid newlines from being interpreted later. set results $first_completion else - # We preprocess the whole list to prevent spawning a slow cut process for each completion. - # From here on, make sure to use -- and zero termination everywhere to prevent introducing + # Preprocess the whole list and extract the actual completion any the corresponding description (if any) + # From here on, make sure to use -- (argument list end) and zero termination everywhere to prevent introducing # new edge-cases where characters are interpreted wrongly - set -l actual_completions (string join0 -- $completions | cut -f1 --zero-terminated | string split0) - set -l descriptions (string join0 -- $completions | cut -f2- --zero-terminated | string split0) + set -l actual_completions + set -l descriptions + for i in (seq (count $completions)) + # split on \t from the right, at most one time and use the first (left) field. + # This is the actual completion + set actual_completions[$i] (string split --fields 1 --max 1 --right \t -- $completions[$i]) + # The other field is the description, if it exists (otherwise set empty). + set descriptions[$i] (string split --fields 2 --max 1 --right \t -- $completions[$i] ; or echo "") + end # Find the common prefix of all completions. Yes this seems to be a hard # thing to do in fish if it should be fast. No external process may be @@ -77,7 +85,7 @@ function _fzf_search_completions --description "Shell completion using fzf" # Detect whether descriptions are present and the length of each completion. set -l has_descriptions false - set -l longest_completion $_fzf_complete_min_description_offset + set -l longest_completion $_fzf_search_completions_min_description_offset for i in (seq (count $completions)) if string match --quiet -- "*"\t"*" $completions[$i] set has_descriptions true @@ -112,7 +120,6 @@ function _fzf_search_completions --description "Shell completion using fzf" --print-query \ $fzf_complete_description_opts \ $fzf_complete_opts \ - | cut -f1 --zero-terminated \ | string split0) switch $pipestatus[2] @@ -128,6 +135,11 @@ function _fzf_search_completions --description "Shell completion using fzf" commandline -f repaint return end + + # Strip anything after last \t (the descriptions) + for i in (seq (count $results)) + set results[$i] (string split --fields 1 --max 1 --right \t -- $results[$i]) + end end set -l prefix "" @@ -164,7 +176,6 @@ function _fzf_search_completions --description "Shell completion using fzf" # If each_prefix is set, we need to apply it to each result # before replacing the token if test -n $each_prefix - echo $each_prefix set -l new_tokens for r in $results set -a new_tokens $each_prefix$r From 122ff4ff22a5fa3606619ca519bc204841e90678 Mon Sep 17 00:00:00 2001 From: oddlama Date: Sun, 5 Mar 2023 14:57:37 +0100 Subject: [PATCH 4/5] chore: increase readability in completion array construction --- functions/_fzf_search_completions.fish | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/_fzf_search_completions.fish b/functions/_fzf_search_completions.fish index 3954dc58..97f896dd 100644 --- a/functions/_fzf_search_completions.fish +++ b/functions/_fzf_search_completions.fish @@ -35,9 +35,9 @@ function _fzf_search_completions --description "Shell completion using fzf" for i in (seq (count $completions)) # split on \t from the right, at most one time and use the first (left) field. # This is the actual completion - set actual_completions[$i] (string split --fields 1 --max 1 --right \t -- $completions[$i]) + set --append actual_completions (string split --fields 1 --max 1 --right \t -- $completions[$i]) # The other field is the description, if it exists (otherwise set empty). - set descriptions[$i] (string split --fields 2 --max 1 --right \t -- $completions[$i] ; or echo "") + set --append descriptions (string split --fields 2 --max 1 --right \t -- $completions[$i] ; or echo "") end # Find the common prefix of all completions. Yes this seems to be a hard From 6331eedaf680323dd5a2e2f7fba37a1bc89d6564 Mon Sep 17 00:00:00 2001 From: oddlama Date: Sun, 12 Mar 2023 15:43:29 +0100 Subject: [PATCH 5/5] chore: use expanded argument syntax for readability --- functions/_fzf_search_completions.fish | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/_fzf_search_completions.fish b/functions/_fzf_search_completions.fish index 97f896dd..db45279d 100644 --- a/functions/_fzf_search_completions.fish +++ b/functions/_fzf_search_completions.fish @@ -10,7 +10,7 @@ set --global _fzf_search_completions_min_description_offset 14 function _fzf_search_completions --description "Shell completion using fzf" # Produce a list of completions from which to choose # and do nothing if there is nothing to select from - set -l cmd (commandline -co) (commandline -t) + set -l cmd (commandline --cut-at-cursor --tokenize) (commandline --current-token) set -l completions (complete --escape --do-complete "$cmd") test (count $completions) -eq 0; and return