├── .gitignore ├── fuzzy-match.zsh ├── LICENSE ├── functions ├── fuzzy-match-key-handlers ├── fuzzy-match-file-glob └── fuzzy-match └── README.mkd /.gitignore: -------------------------------------------------------------------------------- 1 | *.zwc 2 | -------------------------------------------------------------------------------- /fuzzy-match.zsh: -------------------------------------------------------------------------------- 1 | ### this plugin contains snippets/ideas taken from zaw (github.com/zsh-users/zaw) 2 | function () { 3 | 4 | zmodload zsh/parameter 5 | autoload -U is-at-least 6 | 7 | local this_file="${funcsourcetrace[1]%:*}" 8 | if is-at-least 4.3.10; then 9 | # "A" flag (turn a file name into an absolute path with symlink 10 | # resolution) is only available on 4.3.10 and latter 11 | local cur_dir="${this_file:A:h}" 12 | else 13 | local cur_dir="${this_file:h}" 14 | fi 15 | fpath+=("${cur_dir}/functions") 16 | 17 | local file 18 | for file in "$cur_dir/functions/"*(.); do 19 | autoload -U ${file:t} 20 | ${file:t} 21 | done 22 | 23 | local default_keys='^T' map 24 | 25 | for map in emacs viins vicmd; do 26 | bindkey -M $map | "grep" -q 'fuzzy-match' || bindkey -M $map $default_keys fuzzy-match 27 | done 28 | 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /functions/fuzzy-match-key-handlers: -------------------------------------------------------------------------------- 1 | # functions that handle user key presses 2 | 3 | _fuzzy-match-update-line-cursor() { 4 | integer selected_start_idx selected_end_idx selected_line=$state[selected_line]\ 5 | buffer_len=${#BUFFER} 6 | local hi_selected=$state[hi_selected] nl=$state[nl] 7 | # highlight the selected line 8 | # start index to highlight (zshparam(1) for help on the subscript 9 | # syntax) 10 | selected_start_idx=${POSTDISPLAY[(n:((selected_line)):i)$nl]} 11 | (( selected_start_idx += buffer_len )) 12 | # end index 13 | selected_end_idx=$(( ${POSTDISPLAY[(n:((selected_line + 1)):i)$nl]} - 1)) 14 | (( selected_end_idx += buffer_len )) 15 | state[last_selected_line]=$state[selected_line] 16 | region_highlight[1]="$selected_start_idx $selected_end_idx $hi_selected" 17 | zle -Rc 18 | } 19 | 20 | _fuzzy-match-go-up() { 21 | if (( state[selected_line] > 1)); then 22 | (( state[selected_line]-- )) 23 | _fuzzy-match-update-line-cursor 24 | fi 25 | } 26 | 27 | _fuzzy-match-go-down() { 28 | if (( state[selected_line] < state[lines] )); then 29 | (( state[selected_line]++ )) 30 | _fuzzy-match-update-line-cursor 31 | fi 32 | } 33 | 34 | _fuzzy-match-self-insert() { 35 | LBUFFER+=${KEYS[-1]} 36 | print -p -N 'RESTART' 37 | _fuzzy-match-update-line-cursor 38 | } 39 | 40 | _fuzzy-match-backward-delete-char() { 41 | if (( $#LBUFFER )); then 42 | LBUFFER=${LBUFFER[1,-2]} 43 | print -p -N 'RESTART' 44 | _fuzzy-match-update-line-cursor 45 | fi 46 | } 47 | 48 | _fuzzy-match-delete-char() { 49 | if (( $#RBUFFER )); then 50 | RBUFFER=${RBUFFER[2,-1]} 51 | print -p -N 'RESTART' 52 | _fuzzy-match-update-line-cursor 53 | fi 54 | } 55 | 56 | _fuzzy-match-accept-line() { 57 | # extract the highlighted line 58 | local highlighted start end buffer_len=$#BUFFER 59 | highlighted=(${(z)region_highlight[1]}) 60 | start=$(( highlighted[1] + 1 - buffer_len )) 61 | end=$(( highlighted[2] - buffer_len )) 62 | state[text]="${POSTDISPLAY[$start,$end]}" 63 | # exit the nested zle editor 64 | zle .accept-line 65 | } 66 | 67 | unfunction fuzzy-match-key-handlers 68 | -------------------------------------------------------------------------------- /README.mkd: -------------------------------------------------------------------------------- 1 | This project alpha software and not being maintained, I don't recommend using it. Here are better alternatives: 2 | 3 | - [fzf](https://github.com/junegunn/fzf) 4 | - [selecta](https://github.com/garybernhardt/selecta) 5 | 6 | ##Zsh-Fuzzy-Match 7 | 8 | Zsh-Fuzzy-Match is a ZLE widget for interactively finding stuff, inspired by Vim's [CtrlP][control-p] and [Command-T][command-t] plugins. 9 | 10 | [control-p]: https://github.com/kien/ctrlp.vim 11 | [command-t]: https://github.com/wincent/Command-T 12 | 13 | ###Installation 14 | 15 | ```zsh 16 | $ git clone git://github.com/tarruda/zsh-fuzzy-match.git $HOME/.zsh-fuzzy-match 17 | $ echo 'source $HOME/.zsh-fuzzy-match/fuzzy-match.zsh' >> $HOME/.zshrc 18 | ``` 19 | 20 | ###Basic Usage 21 | 22 | CTRL+T is the default binding to activate the widget, it can be overridden 23 | by invoking `'bindkey [KEYS] fuzzy-match'` before sourcing the main file. 24 | 25 | When the widget is running, type parts of a filename and it will list zsh glob 26 | matches, which can be selected using arrows. 27 | 28 | If you invoke the widget with an empty zle buffer, it will open the file in your 29 | $EDITOR. 30 | 31 | You can also start typing a command(eg: 'rm' or 'vi') and then invoke the 32 | widget, when the filename is selected it will be appended to your command line. 33 | 34 | 35 | ###Configuration 36 | 37 | Ignore patterns can be specified with a '.fuzzy-match-ignore' in the working 38 | directory or '.global.fuzzy-match-ignore' in home directory. 39 | 40 | Each line should contain a zsh-like ignore pattern, with the following remarks: 41 | 42 | - If starts with a '/', the pattern is considered relative to the starting 43 | directory instead of the directory currently being scanned 44 | - If ends with a '/', the pattern is a directory-ignore pattern(it wont descend 45 | into directories that match the pattern) 46 | - Single line comments are allowed with '#' 47 | 48 | ####Examples 49 | 50 | Ignore all css files inside the 'compiled' directory in the project root: 51 | 52 | ``` 53 | /compiled/*.css 54 | ``` 55 | 56 | Ignore all .min.js/.min.css files: 57 | 58 | ``` 59 | *.min.(css|js) 60 | ``` 61 | 62 | Here's a good .global.fuzzy-match-ignore: 63 | 64 | ``` 65 | /.(git|svn|bzr|hg)/ # VCS special dir 66 | /node_modules/ # node.js dependencies directory 67 | *.min.(js|css) # minified files 68 | *~ # vim undo files 69 | *.swp # vim swap files 70 | *.pyc # python compiled 71 | *.zwc # zsh compiled 72 | ``` 73 | 74 | -------------------------------------------------------------------------------- /functions/fuzzy-match-file-glob: -------------------------------------------------------------------------------- 1 | # This is the default data source for fuzzy-match, it will 2 | # recursively search for files that matched the user-entered pattern 3 | # 4 | 5 | _fuzzy-match-find() { 6 | print -N "CLEAR" 7 | if [[ -n $1 ]]; then 8 | # FIXME Need to propertly escape so it wont break the constructed 9 | # 'fpattern'. For now just replacing characters like '\' with ? 10 | # Also, probably need to do some kind of processing on user input, so 11 | # characters like '\' will in fact match sub directories 12 | local escaped=${${1:q}//\//?} 13 | _fuzzy-match-walk $CWD $escaped 0 14 | else 15 | _fuzzy-match-walk $CWD "" 0 16 | fi 17 | } 18 | 19 | _fuzzy-match-walk() { 20 | local cwd=$1 21 | local pattern=$2 22 | local matches=$3 23 | local fpattern= 24 | if [[ -n $pattern ]]; then 25 | # explicitly set the scope of the '#ia1' flag so it wont apply to 26 | # the negated patterns 27 | fpattern="$cwd/(((#ia1)*${pattern}*)${PRUNE_REL_FILES})${PRUNE_FILES}(.N)" 28 | else 29 | fpattern="$cwd/(*${PRUNE_REL_FILES})${PRUNE_FILES}(.N)" 30 | fi 31 | local dpattern="$cwd/(*${PRUNE_REL_DIRS})${PRUNE_DIRS}(/N)" 32 | for file in ${~fpattern}; do 33 | print -N "APPEND:${file#$CWD/}" 34 | # no need to keep searching after the screen is filled with results 35 | (( ++matches >= MAX_RESULTS )) && exit 36 | done 37 | for dir in ${~dpattern}; do 38 | _fuzzy-match-walk "$dir" "$pattern" "$matches" 39 | done 40 | } 41 | 42 | _fuzzy-match-load-ignore-patterns() { 43 | # Load the user ignore patterns 44 | PRUNE_REL_DIRS="" 45 | PRUNE_REL_FILES="" 46 | PRUNE_DIRS="" 47 | PRUNE_FILES="" 48 | local rc 49 | for rc in "$HOME/.global.fuzzy-match-ignore" "$CWD/.fuzzy-match-ignore"; do 50 | if [[ -r $rc ]]; then 51 | exec 3<$rc 52 | while read -u 3 ignore_pattern; do 53 | # strip line comments in the ignore file 54 | local ignore_pattern="${ignore_pattern%%\#*}" 55 | ignore_pattern="${ignore_pattern%% #}" 56 | [[ -z $ignore_pattern ]] && continue 57 | if [[ $ignore_pattern -pcre-match ^/ ]]; then 58 | # relative to the base directory 59 | if [[ $ignore_pattern -pcre-match /$ ]]; then 60 | PRUNE_DIRS="$PRUNE_DIRS~$CWD${ignore_pattern%/}" 61 | else 62 | PRUNE_FILES="$PRUNE_FILES~$CWD$ignore_pattern" 63 | fi 64 | else 65 | # relative to the current directory being scanned 66 | if [[ $ignore_pattern -pcre-match /$ ]]; then 67 | PRUNE_REL_DIRS="$PRUNE_REL_DIRS~${ignore_pattern%/}" 68 | else 69 | PRUNE_REL_FILES="$PRUNE_REL_FILES~$ignore_pattern" 70 | fi 71 | fi 72 | done 73 | exec 3>&- 74 | fi 75 | done 76 | # shared this with the finder process 77 | export PRUNE_DIRS PRUNE_FILES PRUNE_REL_DIRS PRUNE_REL_FILES 78 | } 79 | 80 | _fuzzy-match-file-glob() { 81 | zmodload zsh/pcre 82 | setopt extendedglob globdots 83 | 84 | export MAX_RESULTS=$1 85 | export CWD="$(pwd)" 86 | _fuzzy-match-load-ignore-patterns 87 | 88 | local cmd finder_pid 89 | integer restarting=0 # semaphore for avoiding stacked RESTART commands 90 | 91 | # start the finder without a pattern, so the list will be filled when the 92 | # widget starts 93 | _fuzzy-match-find &! 94 | finder_pid=$! 95 | while read -d $'\0' -r cmd &> /dev/null; do 96 | case $cmd in 97 | PATTERN*) 98 | restarting=0 99 | # restart the find using this pattern 100 | _fuzzy-match-find ${cmd#PATTERN\:} &! 101 | finder_pid=$! 102 | ;; 103 | RESTART) 104 | kill $finder_pid &> /dev/null 105 | (( restarting == 1 )) && continue # already restarting, just ignore 106 | if kill -0 $finder_pid &> /dev/null; then 107 | restarting=1 108 | { 109 | # only run a single instance of the finder process at a time, 110 | # so we wait until it exits before requesting the search 111 | # pattern 112 | # 113 | # this is done in the background so zle can continue to read keys 114 | # without buffering RESTART commands 115 | while kill -0 $finder_pid &> /dev/null; do 116 | sleep 0.2 117 | done 118 | # Done waiting for the finder, request the current pattern and 119 | # continue 120 | print -N 'GET' 121 | } &! 122 | else 123 | # finder has already exited, so we request the pattern and wait for 124 | # response 125 | print -N 'GET' 126 | fi 127 | ;; 128 | esac 129 | done 130 | } 131 | 132 | unfunction fuzzy-match-file-glob 133 | -------------------------------------------------------------------------------- /functions/fuzzy-match: -------------------------------------------------------------------------------- 1 | # Main widget logic. 2 | # 3 | # Theoretically, this can be extended by providing a source, 4 | # which can be any program that understands a simple protocol via stdio 5 | # 6 | # It can send: 7 | # 8 | # CLEAR - Clears the output list 9 | # APPEND:[result] - Appends a single result to the output list 10 | # GET - Queries for the currently typed pattern 11 | # 12 | # It should also accept: 13 | # 14 | # PATTERN:[pattern] - This is the response to GET 15 | # RESTART - Stop yielding matches and start again(this happens when the 16 | # user modified the text) 17 | # 18 | # Every message in the protocol should be terminated with the null byte(0) 19 | fuzzy-match() { 20 | emulate -L zsh 21 | setopt local_options extended_glob 22 | unsetopt monitor 23 | 24 | # Save current zle state 25 | typeset -Ag old_state state 26 | old_state=() 27 | old_state[LBUFFER]=$LBUFFER 28 | old_state[RBUFFER]=$RBUFFER 29 | old_state[PREDISPLAY]=$PREDISPLAY 30 | old_state[POSTDISPLAY]=$POSTDISPLAY 31 | if [[ ${#region_highlight} -ne 0 ]]; then 32 | set -a "old_state[region_highlight]" ("${region_highlight[@]}") 33 | fi 34 | BUFFER='' 35 | local action=$2 36 | 37 | # TODO find a way to match newlines in subcript glob patterns without using 38 | # this hack 39 | IFS=$'\n' 40 | # widget-specific state, global for easy sharing across functions. 41 | state=( 42 | nl '[[:IFS:]]' 43 | selected_line 0 44 | last_selected_line 0 45 | lines 0 46 | max_lines $(( LINES - 3 )) 47 | text '' 48 | ) 49 | 50 | # initialize configuration 51 | zstyle -s ':fuzzy-match:highlight' selected "state[hi_selected]" ||\ 52 | state[hi_selected]='standout' 53 | zstyle -s ':fuzzy-match:highlight' matched "state[hi_matched]" ||\ 54 | state[hi_matched]='fg=green,underline' 55 | zstyle -s ':fuzzy-match:highlight' title "state[hi_title]" ||\ 56 | state[hi_title]='bold' 57 | 58 | # The first item in 'region_highlight' is used for the line cursor, second for 59 | # the title 60 | PREDISPLAY=$'\nFuzzy match: ' 61 | region_highlight=("" "P1 13 $state[hi_title]") 62 | 63 | # initialize source program 64 | local source_prog=$1 65 | if [[ -z $source_prog ]]; then 66 | source_prog="_fuzzy-match-file-glob" 67 | fi 68 | 69 | # start the source prog as a coprocess 70 | coproc $source_prog $state[max_lines] 71 | local source_pid=$! 72 | disown 73 | local coproc_out 74 | 75 | # copy coproc fd to $coproc_out so it can be used by zle -F 76 | exec {coproc_out}<&p 77 | 78 | # make zle invoke _fuzzy-match-coproc-receive 79 | zle -F $coproc_out _fuzzy-match-coproc-receive 80 | 81 | # Intercept basic editing widgets 82 | zle -N accept-line _fuzzy-match-accept-line 83 | zle -N self-insert _fuzzy-match-self-insert 84 | zle -N backward-delete-char _fuzzy-match-backward-delete-char 85 | zle -N vi-backward-delete-char _fuzzy-match-backward-delete-char 86 | zle -N delete-char _fuzzy-match-delete-char 87 | zle -N vi-delete-char _fuzzy-match-delete-char 88 | zle -N up-line-or-history _fuzzy-match-go-up 89 | zle -N down-line-or-history _fuzzy-match-go-down 90 | 91 | # use recursive edit, so we inherit the user keymap and override only what is 92 | # needed 93 | zle recursive-edit 94 | 95 | # restore default widgets 96 | zle -A .accept-line accept-line 97 | zle -A .self-insert self-insert 98 | zle -A .backward-delete-char backward-delete-char 99 | zle -A .vi-backward-delete-char vi-backward-delete-char 100 | zle -A .delete-char delete-char 101 | zle -A .vi-delete-char vi-delete-char 102 | zle -A .up-line-or-history up-line-or-history 103 | zle -A .down-line-or-history down-line-or-history 104 | 105 | # restore initial zle state 106 | LBUFFER=$old_state[LBUFFER] 107 | RBUFFER=$old_state[RBUFFER] 108 | PREDISPLAY=$old_state[PREDISPLAY] 109 | POSTDISPLAY=$old_state[POSTDISPLAY] 110 | if [[ -n $old_state[region_highlight] ]]; then 111 | region_highlight=($old_state[region_highlight]) 112 | else 113 | region_highlight=() 114 | fi 115 | 116 | # refresh 117 | zle -Rc 118 | 119 | # if something was selected, save before deleting the state hashtable 120 | local text=$state[text] 121 | 122 | # delete globals set 123 | unset old_state state IFS 124 | 125 | # cleanup source process 126 | kill $source_pid &> /dev/null 127 | 128 | echo "$@" > /tmp/args 129 | if [[ -n $text ]]; then 130 | if [[ -n $3 ]]; then 131 | # set REPLY and return to the calling widget 132 | REPLY=$text 133 | elif [[ -z $LBUFFER ]]; then 134 | # Nothing was typed, if an action was provided as second argument, invoke 135 | # it, else just edit the file 136 | if [[ -n $2 ]]; then 137 | LBUFFER="$2 ${text:q}" 138 | else 139 | LBUFFER="${EDITOR:-vi} ${text:q}" 140 | fi 141 | zle accept-line 142 | else 143 | # append the filename and let the user confirm 144 | if [[ $LBUFFER[-1] != $' ' ]]; then 145 | LBUFFER+=$' ' 146 | fi 147 | LBUFFER+=${text:q} 148 | zle -Rc 149 | fi 150 | else 151 | zle send-break 152 | fi 153 | } 154 | 155 | _fuzzy-match-coproc-receive() { 156 | local cmd item hi_selected=$state[hi_selected] 157 | integer buffer_len base_idx 158 | if read -d $'\0' -r -u $1 cmd; then 159 | case $cmd in 160 | CLEAR) 161 | POSTDISPLAY='' 162 | state[lines]=0 163 | state[selected_line]=0 164 | state[last_selected_line]=0 165 | region_highlight=$region_highlight[1,2] 166 | ;; 167 | APPEND*) 168 | (( state[lines] == state[max_lines] )) && return 169 | buffer_len=${#BUFFER} 170 | (( base_idx = buffer_len + 1 )) 171 | item=${cmd#APPEND\:} 172 | _fuzzy-match-highlight-item $(( buffer_len + $#POSTDISPLAY )) $item 173 | POSTDISPLAY+=$'\n' 174 | POSTDISPLAY+=$item 175 | (( state[lines]++ )) 176 | if (( state[selected_line] == 0 )); then 177 | # automatically select the first line 178 | state[selected_line]=1 179 | region_highlight[1]="$base_idx $(( base_idx + $#item )) $hi_selected" 180 | fi 181 | state[incomplete_line]='' 182 | ;; 183 | GET) 184 | print -p -N "PATTERN:$BUFFER" 185 | ;; 186 | esac 187 | zle -Rc 188 | fi 189 | } 190 | 191 | # highlight a single result. this constructs a pattern with the buffer escaped 192 | # contents, and appends the #ia1 flags for approximate/case insensitive 193 | # matching. also, since there seems to be no easy way to find the exact match 194 | # length, we use the buffer length for limiting the highlighted text 195 | _fuzzy-match-highlight-item() { 196 | local idx len end_idx start_idx=$1 item=$2 hi_matched=$state[hi_matched]\ 197 | pattern="(#ia1)(${${BUFFER:q}//\//?})" 198 | # find the index of the first match after the last '/' (the filename) 199 | idx=${${item##*/}[(i)$pattern]} 200 | if (( ${item[(i)/]} <= $#item )); then 201 | (( idx += ${#${item%/*}} + 1 )) 202 | fi 203 | (( idx > $#item )) && return 204 | (( start_idx += idx )) 205 | (( end_idx = start_idx + $#BUFFER )) 206 | region_highlight+="$start_idx $end_idx $hi_matched" 207 | } 208 | 209 | 210 | zle -N fuzzy-match 211 | --------------------------------------------------------------------------------