diff --git a/.gitignore b/.gitignore index 1627430..0915467 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +bin +src/fzf/fzf_* pkg Gemfile.lock .DS_Store diff --git a/.travis.yml b/.travis.yml index 7633c4f..cbd5c11 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,24 @@ language: ruby -sudo: false rvm: - - "1.8.7" - - "1.9.3" - - "2.0.0" - - "2.1.1" +- 2.2.0 -install: gem install curses minitest +install: +- sudo apt-get update +- sudo apt-get install -y libncurses-dev lib32ncurses5-dev +- sudo add-apt-repository -y ppa:pi-rho/dev +- sudo apt-add-repository -y ppa:fish-shell/release-2 +- sudo apt-get update +- sudo apt-get install -y tmux=1.9a-1~ppa1~p +- sudo apt-get install -y zsh fish +script: | + export GOPATH=~/go + export FZF_BASE=$GOPATH/src/github.com/junegunn/fzf + + mkdir -p $GOPATH/src/github.com/junegunn + ln -s $(pwd) $FZF_BASE + + cd $FZF_BASE/src && make test fzf/fzf-linux_amd64 install && + cd $FZF_BASE/bin && ln -sf fzf-linux_amd64 fzf-$(./fzf --version)-linux_amd64 && + cd $FZF_BASE && yes | ./install && rm -f fzf && + tmux new "ruby test/test_go.rb > out && touch ok" && cat out && [ -e ok ] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f10cbca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,302 @@ +CHANGELOG +========= + +0.10.6 +------ + +- Replaced `--header-file` with `--header` option +- `--header` and `--header-lines` can be used together +- Changed exit status + - 0: Okay + - 1: No match + - 2: Error/Interrupted + +0.10.5 +------ + +- `'`-prefix to unquote the term in `--extended-exact` mode +- Backward scan when `--tiebreak=end` is set + +0.10.4 +------ + +- Fixed to remove ANSI code from output when `--with-nth` is set + +0.10.3 +------ + +- Fixed slow performance of `--with-nth` when used with `--delimiter` + - Regular expression engine of Golang as of now is very slow, so the fixed + version will treat the given delimiter pattern as a plain string instead + of a regular expression unless it contains special characters and is + a valid regular expression. + - Simpler regular expression for delimiter for better performance + +0.10.2 +------ + +### Fixes and improvements + +- Improvement in perceived response time of queries + - Eager, efficient rune array conversion +- Graceful exit when failed to initialize ncurses (invalid $TERM) +- Improved ranking algorithm when `--nth` option is set +- Changed the default command not to fail when there are files whose names + start with dash + +0.10.1 +------ + +### New features + +- Added `--margin` option +- Added options for sticky header + - `--header-file` + - `--header-lines` +- Added `cancel` action which clears the input or closes the finder when the + input is already empty + - e.g. `export FZF_DEFAULT_OPTS="--bind esc:cancel"` +- Added `delete-char/eof` action to differentiate `CTRL-D` and `DEL` + +### Minor improvements/fixes + +- Fixed to allow binding colon and comma keys +- Fixed ANSI processor to handle color regions spanning multiple lines + +0.10.0 +------ + +### New features + +- More actions for `--bind` + - `select-all` + - `deselect-all` + - `toggle-all` + - `ignore` +- `execute(...)` action for running arbitrary command without leaving fzf + - `fzf --bind "ctrl-m:execute(less {})"` + - `fzf --bind "ctrl-t:execute(tmux new-window -d 'vim {}')"` + - If the command contains parentheses, use any of the follows alternative + notations to avoid parse errors + - `execute[...]` + - `execute~...~` + - `execute!...!` + - `execute@...@` + - `execute#...#` + - `execute$...$` + - `execute%...%` + - `execute^...^` + - `execute&...&` + - `execute*...*` + - `execute;...;` + - `execute/.../` + - `execute|...|` + - `execute:...` + - This is the special form that frees you from parse errors as it + does not expect the closing character + - The catch is that it should be the last one in the + comma-separated list +- Added support for optional search history + - `--history HISTORY_FILE` + - When used, `CTRL-N` and `CTRL-P` are automatically remapped to + `next-history` and `previous-history` + - `--history-size MAX_ENTRIES` (default: 1000) +- Cyclic scrolling can be enabled with `--cycle` +- Fixed the bug where the spinner was not spinning on idle input stream + - e.g. `sleep 100 | fzf` + +### Minor improvements/fixes + +- Added synonyms for key names that can be specified for `--bind`, + `--toggle-sort`, and `--expect` +- Fixed the color of multi-select marker on the current line +- Fixed to allow `^pattern$` in extended-search mode + + +0.9.13 +------ + +### New features + +- Color customization with the extended `--color` option + +### Bug fixes + +- Fixed premature termination of Reader in the presence of a long line which + is longer than 64KB + +0.9.12 +------ + +### New features + +- Added `--bind` option for custom key bindings + +### Bug fixes + +- Fixed to update "inline-info" immediately after terminal resize +- Fixed ANSI code offset calculation + +0.9.11 +------ + +### New features + +- Added `--inline-info` option for saving screen estate (#202) + - Useful inside Neovim + - e.g. `let $FZF_DEFAULT_OPTS = $FZF_DEFAULT_OPTS.' --inline-info'` + +### Bug fixes + +- Invalid mutation of input on case conversion (#209) +- Smart-case for each term in extended-search mode (#208) +- Fixed double-click result when scroll offset is positive + +0.9.10 +------ + +### Improvements + +- Performance optimization +- Less aggressive memoization to limit memory usage + +### New features + +- Added color scheme for light background: `--color=light` + +0.9.9 +----- + +### New features + +- Added `--tiebreak` option (#191) +- Added `--no-hscroll` option (#193) +- Visual indication of `--toggle-sort` (#194) + +0.9.8 +----- + +### Bug fixes + +- Fixed Unicode case handling (#186) +- Fixed to terminate on RuneError (#185) + +0.9.7 +----- + +### New features + +- Added `--toggle-sort` option (#173) + - `--toggle-sort=ctrl-r` is applied to `CTRL-R` shell extension + +### Bug fixes + +- Fixed to print empty line if `--expect` is set and fzf is completed by + `--select-1` or `--exit-0` (#172) +- Fixed to allow comma character as an argument to `--expect` option + +0.9.6 +----- + +### New features + +#### Added `--expect` option (#163) + +If you provide a comma-separated list of keys with `--expect` option, fzf will +allow you to select the match and complete the finder when any of the keys is +pressed. Additionally, fzf will print the name of the key pressed as the first +line of the output so that your script can decide what to do next based on the +information. + +```sh +fzf --expect=ctrl-v,ctrl-t,alt-s,f1,f2,~,@ +``` + +The updated vim plugin uses this option to implement +[ctrlp](https://github.com/kien/ctrlp.vim)-compatible key bindings. + +### Bug fixes + +- Fixed to ignore ANSI escape code `\e[K` (#162) + +0.9.5 +----- + +### New features + +#### Added `--ansi` option (#150) + +If you give `--ansi` option to fzf, fzf will interpret ANSI color codes from +the input, display the item with the ANSI colors (true colors are not +supported), and strips the codes from the output. This option is off by +default as it entails some overhead. + +### Improvements + +#### Reduced initial memory footprint (#151) + +By removing unnecessary copy of pointers, fzf will use significantly smaller +amount of memory when it's started. The difference is hugely noticeable when +the input is extremely large. (e.g. `locate / | fzf`) + +### Bug fixes + +- Fixed panic on `--no-sort --filter ''` (#149) + +0.9.4 +----- + +### New features + +#### Added `--tac` option to reverse the order of the input. + +One might argue that this option is unnecessary since we can already put `tac` +or `tail -r` in the command pipeline to achieve the same result. However, the +advantage of `--tac` is that it does not block until the input is complete. + +### *Backward incompatible changes* + +#### Changed behavior on `--no-sort` + +`--no-sort` option will no longer reverse the display order within finder. You +may want to use the new `--tac` option with `--no-sort`. + +``` +history | fzf +s --tac +``` + +### Improvements + +#### `--filter` will not block when sort is disabled + +When fzf works in filtering mode (`--filter`) and sort is disabled +(`--no-sort`), there's no need to block until input is complete. The new +version of fzf will print the matches on-the-fly when the following condition +is met: + + --filter TERM --no-sort [--no-tac --no-sync] + +or simply: + + -f TERM +s + +This change removes unnecessary delay in the use cases like the following: + + fzf -f xxx +s | head -5 + +However, in this case, fzf processes the lines sequentially, so it cannot +utilize multiple cores, and fzf will run slightly slower than the previous +mode of execution where filtering is done in parallel after the entire input +is loaded. If the user is concerned about this performance problem, one can +add `--sync` option to re-enable buffering. + +0.9.3 +----- + +### New features +- Added `--sync` option for multi-staged filtering + +### Improvements +- `--select-1` and `--exit-0` will start finder immediately when the condition + cannot be met + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe4c31a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Junegunn Choi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 0b52864..956dd1e 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,62 @@ -fzf - Fuzzy finder for your shell -================================= +fzf - a command-line fuzzy finder [![travis-ci](https://travis-ci.org/junegunn/fzf.svg?branch=master)](https://travis-ci.org/junegunn/fzf) +=== -fzf is a general-purpose fuzzy finder for your shell. +fzf is a general-purpose command-line fuzzy finder. ![](https://raw.github.com/junegunn/i/master/fzf.gif) -It was heavily inspired by [ctrlp.vim](https://github.com/kien/ctrlp.vim) and -the likes. +Pros +---- -Requirements ------------- - -fzf requires Ruby (>= 1.8.5). +- No dependencies +- Blazingly fast + - e.g. `locate / | fzf` +- Flexible layout + - Runs in fullscreen or in horizontal/vertical split using tmux +- The most comprehensive feature set + - Try `fzf --help` and be surprised +- Batteries included + - Vim/Neovim plugin, key bindings and fuzzy auto-completion Installation ------------ +fzf project consists of the followings: + +- `fzf` executable +- `fzf-tmux` script for launching fzf in a tmux pane +- Shell extensions + - Key bindings (`CTRL-T`, `CTRL-R`, and `ALT-C`) (bash, zsh, fish) + - Fuzzy auto-completion (bash, zsh) +- Vim/Neovim plugin + +You can [download fzf executable][bin] alone, but it's recommended that you +install the extra stuff using the attached install script. + +[bin]: https://github.com/junegunn/fzf-bin/releases + +#### Using git (recommended) + Clone this repository and run [install](https://github.com/junegunn/fzf/blob/master/install) script. ```sh -git clone https://github.com/junegunn/fzf.git ~/.fzf +git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf ~/.fzf/install ``` -In case you don't have git installed: +#### Using Homebrew + +On OS X, you can use [Homebrew](http://brew.sh/) to install fzf. ```sh -mkdir -p ~/.fzf -curl -L https://github.com/junegunn/fzf/archive/master.tar.gz | - tar xz --strip-components 1 -C ~/.fzf -~/.fzf/install +brew reinstall --HEAD fzf + +# Install shell extensions +/usr/local/Cellar/fzf/HEAD/install ``` -The script will setup: - -- `fzf` function (bash, zsh, fish) -- Key bindings (`CTRL-T`, `CTRL-R`, and `ALT-C`) (bash, zsh, fish) -- Fuzzy auto-completion (bash) - -If you don't use any of the aforementioned shells, you have to manually place -fzf executable in a directory included in `$PATH`. Key bindings and -auto-completion will not be available in that case. - -### Install as Vim plugin +#### Install as Vim plugin Once you have cloned the repository, add the following line to your .vimrc. @@ -51,55 +64,26 @@ Once you have cloned the repository, add the following line to your .vimrc. set rtp+=~/.fzf ``` -Or you may use [vim-plug](https://github.com/junegunn/vim-plug) to manage fzf -inside Vim: +Or you can have [vim-plug](https://github.com/junegunn/vim-plug) manage fzf +(recommended): ```vim Plug 'junegunn/fzf', { 'dir': '~/.fzf', 'do': 'yes \| ./install' } ``` +#### Upgrading fzf + +fzf is being actively developed and you might want to upgrade it once in a +while. Please follow the instruction below depending on the installation +method. + +- git: `cd ~/.fzf && git pull && ./install` +- brew: `brew reinstall --HEAD fzf` +- vim-plug: `:PlugUpdate fzf` + Usage ----- -``` -usage: fzf [options] - - Search - -x, --extended Extended-search mode - -e, --extended-exact Extended-search mode (exact match) - -i Case-insensitive match (default: smart-case match) - +i Case-sensitive match - -n, --nth=N[,..] Comma-separated list of field index expressions - for limiting search scope. Each can be a non-zero - integer or a range expression ([BEGIN]..[END]) - --with-nth=N[,..] Transform the item using index expressions for search - -d, --delimiter=STR Field delimiter regex for --nth (default: AWK-style) - - Search result - -s, --sort=MAX Maximum number of matched items to sort (default: 1000) - +s, --no-sort Do not sort the result. Keep the sequence unchanged. - - Interface - -m, --multi Enable multi-select with tab/shift-tab - --no-mouse Disable mouse - +c, --no-color Disable colors - +2, --no-256 Disable 256-color - --black Use black background - --reverse Reverse orientation - --prompt=STR Input prompt (default: '> ') - - Scripting - -q, --query=STR Start the finder with the given query - -1, --select-1 Automatically select the only match - -0, --exit-0 Exit immediately when there's no match - -f, --filter=STR Filter mode. Do not start interactive finder. - --print-query Print query as the first line - - Environment variables - FZF_DEFAULT_COMMAND Default command to use when input is tty - FZF_DEFAULT_OPTS Defaults options. (e.g. "-x -m --sort 10000") -``` - fzf will launch curses-based finder, read the list from STDIN, and write the selected item to STDOUT. @@ -115,34 +99,16 @@ files excluding hidden ones. (You can override the default command with vim $(fzf) ``` -If you want to preserve the exact sequence of the input, provide `--no-sort` (or -`+s`) option. +#### Using the finder -```sh -history | fzf +s -``` +- `CTRL-J` / `CTRL-K` (or `CTRL-N` / `CTRL-P)` to move cursor up and down +- `Enter` key to select the item, `CTRL-C` / `CTRL-G` / `ESC` to exit +- On multi-select mode (`-m`), `TAB` and `Shift-TAB` to mark multiple items +- Emacs style key bindings +- Mouse: scroll, click, double-click; shift-click and shift-scroll on + multi-select mode -### Keys - -Use CTRL-J and CTRL-K (or CTRL-N and CTRL-P) to change the selection, press -enter key to select the item. CTRL-C, CTRL-G, or ESC will terminate the finder. - -The following readline key bindings should also work as expected. - -- CTRL-A / CTRL-E -- CTRL-B / CTRL-F -- CTRL-H / CTRL-D -- CTRL-W / CTRL-U / CTRL-Y -- ALT-B / ALT-F - -If you enable multi-select mode with `-m` option, you can select multiple items -with TAB or Shift-TAB key. - -You can also use mouse. Double-click on an item to select it or shift-click (or -ctrl-click) to select multiple items. Use mouse wheel to move the cursor up and -down. - -### Extended-search mode +#### Extended-search mode With `-x` or `--extended` option, fzf will start in "extended-search mode". @@ -158,43 +124,23 @@ such as: `^music .mp3$ sbtrkt !rmx` | `'wild` | Items that include `wild` | exact-match (quoted) | | `!'fire` | Items that do not include `fire` | inverse-exact-match | -If you don't need fuzzy matching and do not wish to "quote" every word, start -fzf with `-e` or `--extended-exact` option. +If you don't prefer fuzzy matching and do not wish to "quote" every word, +start fzf with `-e` or `--extended-exact` option. Note that in +`--extended-exact` mode, `'`-prefix "unquotes" the term. -Useful examples ---------------- +#### Environment variables -```sh -# fe [FUZZY PATTERN] - Open the selected file with the default editor -# - Bypass fuzzy finder if there's only one match (--select-1) -# - Exit if there's no match (--exit-0) -fe() { - local file - file=$(fzf --query="$1" --select-1 --exit-0) - [ -n "$file" ] && ${EDITOR:-vim} "$file" -} +- `FZF_DEFAULT_COMMAND` + - Default command to use when input is tty +- `FZF_DEFAULT_OPTS` + - Default options. e.g. `export FZF_DEFAULT_OPTS="--extended --cycle"` -# fd - cd to selected directory -fd() { - local dir - dir=$(find ${1:-*} -path '*/\.*' -prune \ - -o -type d -print 2> /dev/null | fzf +m) && - cd "$dir" -} +Examples +-------- -# fh - repeat history -fh() { - eval $(([ -n "$ZSH_NAME" ] && fc -l 1 || history) | fzf +s | sed 's/ *[0-9]* *//') -} - -# fkill - kill process -fkill() { - ps -ef | sed 1d | fzf -m | awk '{print $2}' | xargs kill -${1:-9} -} -``` - -For more examples, see [the wiki -page](https://github.com/junegunn/fzf/wiki/examples). +Many useful examples can be found on [the wiki +page](https://github.com/junegunn/fzf/wiki/examples). Feel free to add your +own as well. Key bindings for command line ----------------------------- @@ -202,29 +148,42 @@ Key bindings for command line The install script will setup the following key bindings for bash, zsh, and fish. -- `CTRL-T` - Paste the selected file path(s) into the command line -- `CTRL-R` - Paste the selected command from history into the command line +- `CTRL-T` - Paste the selected files and directories onto the command line + - Set `FZF_CTRL_T_COMMAND` to override the default command +- `CTRL-R` - Paste the selected command from history onto the command line + - Sort is disabled by default to respect chronological ordering + - Press `CTRL-R` again to toggle sort - `ALT-C` - cd into the selected directory -If you're on a tmux session, `CTRL-T` will launch fzf in a new split-window. You -may disable this tmux integration by setting `FZF_TMUX` to 0, or change the -height of the window with `FZF_TMUX_HEIGHT` (e.g. `20`, `50%`). +If you're on a tmux session, fzf will start in a split pane. You may disable +this tmux integration by setting `FZF_TMUX` to 0, or change the height of the +pane with `FZF_TMUX_HEIGHT` (e.g. `20`, `50%`). If you use vi mode on bash, you need to add `set -o vi` *before* `source ~/.fzf.bash` in your .bashrc, so that it correctly sets up key bindings for vi mode. -If you want to customize the key bindings, consider editing the -installer-generated source code: `~/.fzf.bash`, `~/.fzf.zsh`, and -`~/.config/fish/functions/fzf_key_bindings.fish`. +`fzf-tmux` script +----------------- -Auto-completion ---------------- +[fzf-tmux](bin/fzf-tmux) is a bash script that opens fzf in a tmux pane. -Disclaimer: *Auto-completion feature is currently experimental, it can change -over time* +```sh +# usage: fzf-tmux [-u|-d [HEIGHT[%]]] [-l|-r [WIDTH[%]]] [--] [FZF OPTIONS] +# (-[udlr]: up/down/left/right) -### bash +# select git branches in horizontal split below (15 lines) +git branch | fzf-tmux -d 15 + +# select multiple words in vertical split on the left (20% of screen width) +cat /usr/share/dict/words | fzf-tmux -l 20% --multi --reverse +``` + +It will still work even when you're not on tmux, silently ignoring `-[udlr]` +options, so you can invariably use `fzf-tmux` in your scripts. + +Fuzzy completion for bash and zsh +--------------------------------- #### Files and directories @@ -293,18 +252,15 @@ export FZF_COMPLETION_TRIGGER='~~' export FZF_COMPLETION_OPTS='+c -x' ``` -### zsh - -TODO :smiley: - -(Pull requests are appreciated.) - Usage as Vim plugin ------------------- +This repository only enables basic integration with Vim. If you're looking for +more, check out [fzf.vim](https://github.com/junegunn/fzf.vim) project. + (Note: To use fzf in GVim, an external terminal emulator is required.) -### `:FZF[!]` +#### `:FZF[!]` If you have set up fzf for Vim, `:FZF` command will be added. @@ -317,142 +273,61 @@ If you have set up fzf for Vim, `:FZF` command will be added. " With options :FZF --no-sort -m /tmp + +" Bang version starts in fullscreen instead of using tmux pane or Neovim split +:FZF! ``` -Note that the environment variables `FZF_DEFAULT_COMMAND` and `FZF_DEFAULT_OPTS` -also apply here. +Similarly to [ctrlp.vim](https://github.com/kien/ctrlp.vim), use enter key, +`CTRL-T`, `CTRL-X` or `CTRL-V` to open selected files in the current window, +in new tabs, in horizontal splits, or in vertical splits respectively. -If you're on a tmux session, `:FZF` will launch fzf in a new split-window whose -height can be adjusted with `g:fzf_tmux_height` (default: '40%'). However, the -bang version (`:FZF!`) will always start in fullscreen. +Note that the environment variables `FZF_DEFAULT_COMMAND` and +`FZF_DEFAULT_OPTS` also apply here. Refer to [the wiki page][fzf-config] for +customization. -In GVim, you need an external terminal emulator to start fzf with. `xterm` -command is used by default, but you can customize it with `g:fzf_launcher`. +[fzf-config]: https://github.com/junegunn/fzf/wiki/Configuring-FZF-command-(vim) -```vim -" This is the default. %s is replaced with fzf command -let g:fzf_launcher = 'xterm -e bash -ic %s' +#### `fzf#run([options])` -" Use urxvt instead -let g:fzf_launcher = 'urxvt -geometry 120x30 -e sh -c %s' -``` +For more advanced uses, you can use `fzf#run()` function with the following +options. -If you're running MacVim on OSX, I recommend you to use iTerm2 as the launcher. -Refer to the [this wiki -page](https://github.com/junegunn/fzf/wiki/fzf-with-MacVim-and-iTerm2) to see -how to set up. +| Option name | Type | Description | +| -------------------------- | ------------- | ---------------------------------------------------------------- | +| `source` | string | External command to generate input to fzf (e.g. `find .`) | +| `source` | list | Vim list as input to fzf | +| `sink` | string | Vim command to handle the selected item (e.g. `e`, `tabe`) | +| `sink` | funcref | Reference to function to process each selected item | +| `sink*` | funcref | Similar to `sink`, but takes the list of output lines at once | +| `options` | string | Options to fzf | +| `dir` | string | Working directory | +| `up`/`down`/`left`/`right` | number/string | Use tmux pane with the given size (e.g. `20`, `50%`) | +| `window` (*Neovim only*) | string | Command to open fzf window (e.g. `vertical aboveleft 30new`) | +| `launcher` | string | External terminal emulator to start fzf with (GVim only) | +| `launcher` | funcref | Function for generating `launcher` string (GVim only) | -### `fzf#run([options])` - -For more advanced uses, you can call `fzf#run()` function which returns the list -of the selected items. - -`fzf#run()` may take an options-dictionary: - -| Option name | Type | Description | -| --------------- | ------------- | ------------------------------------------------------------------ | -| `source` | string | External command to generate input to fzf (e.g. `find .`) | -| `source` | list | Vim list as input to fzf | -| `sink` | string | Vim command to handle the selected item (e.g. `e`, `tabe`) | -| `sink` | funcref | Reference to function to process each selected item | -| `options` | string | Options to fzf | -| `dir` | string | Working directory | -| `tmux_width` | number/string | Use tmux vertical split with the given height (e.g. `20`, `50%`) | -| `tmux_height` | number/string | Use tmux horizontal split with the given height (e.g. `20`, `50%`) | -| `launcher` | string | External terminal emulator to start fzf with (Only used in GVim) | - -#### Examples - -If `sink` option is not given, `fzf#run` will simply return the list. - -```vim -let items = fzf#run({ 'options': '-m +c', 'dir': '~', 'source': 'ls' }) -``` - -But if `sink` is given as a string, the command will be executed for each -selected item. - -```vim -" Each selected item will be opened in a new tab -let items = fzf#run({ 'sink': 'tabe', 'options': '-m +c', 'dir': '~', 'source': 'ls' }) -``` - -We can also use a Vim list as the source as follows: - -```vim -" Choose a color scheme with fzf -nnoremap C :call fzf#run({ -\ 'source': -\ map(split(globpath(&rtp, "colors/*.vim"), "\n"), -\ "substitute(fnamemodify(v:val, ':t'), '\\..\\{-}$', '', '')"), -\ 'sink': 'colo', -\ 'options': '+m', -\ 'tmux_width': 20, -\ 'launcher': 'xterm -geometry 20x30 -e bash -ic %s' -\ }) -``` - -`sink` option can be a function reference. The following example creates a -handy mapping that selects an open buffer. - -```vim -" List of buffers -function! BufList() - redir => ls - silent ls - redir END - return split(ls, '\n') -endfunction - -function! BufOpen(e) - execute 'buffer '. matchstr(a:e, '^[ 0-9]*') -endfunction - -nnoremap :call fzf#run({ -\ 'source': reverse(BufList()), -\ 'sink': function('BufOpen'), -\ 'options': '+m', -\ 'tmux_height': '40%' -\ }) -``` - -### Articles - -- [fzf+vim+tmux](http://junegunn.kr/2014/04/fzf+vim+tmux) +Examples can be found on [the wiki +page](https://github.com/junegunn/fzf/wiki/Examples-(vim)). Tips ---- -### Rendering issues +#### Rendering issues If you have any rendering issues, check the followings: 1. Make sure `$TERM` is correctly set. fzf will use 256-color only if it - contains `256` (e.g. `xterm-256color`) + contains `256` (e.g. `xterm-256color`) 2. If you're on screen or tmux, `$TERM` should be either `screen` or - `screen-256color` + `screen-256color` 3. Some terminal emulators (e.g. mintty) have problem displaying default - background color and make some text unable to read. In that case, try `--black` - option. And if it solves your problem, I recommend including it in - `FZF_DEFAULT_OPTS` for further convenience. + background color and make some text unable to read. In that case, try + `--black` option. And if it solves your problem, I recommend including it + in `FZF_DEFAULT_OPTS` for further convenience. 4. If you still have problem, try `--no-256` option or even `--no-color`. -5. Ruby 1.9 or above is required for correctly displaying unicode characters. -### Ranking algorithm - -fzf sorts the result first by the length of the matched substring, then by the -length of the whole string. However it only does so when the number of matches -is less than the limit which is by default 1000, in order to avoid the cost of -sorting a large list and limit the response time of the query. - -This limit can be adjusted with `-s` option, or with the environment variable -`FZF_DEFAULT_OPTS`. - -```sh -export FZF_DEFAULT_OPTS="--sort 20000" -``` - -### Respecting `.gitignore`, `.hgignore`, and `svn:ignore` +#### Respecting `.gitignore`, `.hgignore`, and `svn:ignore` [ag](https://github.com/ggreer/the_silver_searcher) or [pt](https://github.com/monochromegane/the_platinum_searcher) will do the @@ -467,62 +342,23 @@ export FZF_DEFAULT_COMMAND='ag -l -g ""' # Now fzf (w/o pipe) will use ag instead of find fzf + +# To apply the command to CTRL-T as well +export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND" ``` -### `git ls-tree` for fast traversal +#### `git ls-tree` for fast traversal If you're running fzf in a large git repository, `git ls-tree` can boost up the speed of the traversal. ```sh -# Copy the original fzf function to __fzf -declare -f __fzf > /dev/null || - eval "$(echo "__fzf() {"; declare -f fzf | \grep -v '^{' | tail -n +2)" - -# Use git ls-tree when possible -fzf() { - if [ -n "$(git rev-parse HEAD 2> /dev/null)" ]; then - FZF_DEFAULT_COMMAND="git ls-tree -r --name-only HEAD" __fzf "$@" - else - __fzf "$@" - fi -} +export FZF_DEFAULT_COMMAND=' + (git ls-tree -r --name-only HEAD || + find * -name ".*" -prune -o -type f -print -o -type l -print) 2> /dev/null' ``` -### Using fzf with tmux splits - -It isn't too hard to write your own fzf-tmux combo like the default -CTRL-T key binding. (Or is it?) - -```sh -# This is a helper function that splits the current pane to start the given -# command ($1) and sends its output back to the original pane with any number of -# optional keys (shift; $*). -fzf_tmux_helper() { - [ -n "$TMUX_PANE" ] || return - local cmd=$1 - shift - tmux split-window -p 40 \ - "bash -c \"\$(tmux send-keys -t $TMUX_PANE \"\$(source ~/.fzf.bash; $cmd)\" $*)\"" -} - -# This is the function we are going to run in the split pane. -# - "find" to list the directories -# - "sed" will escape spaces in the paths. -# - "paste" will join the selected paths into a single line -fzf_tmux_dir() { - fzf_tmux_helper \ - 'find * -path "*/\.*" -prune -o -type d -print 2> /dev/null | - fzf --multi | - sed "s/ /\\\\ /g" | - paste -sd" " -' Space -} - -# Bind CTRL-X-CTRL-D to fzf_tmux_dir -bind '"\C-x\C-d": "$(fzf_tmux_dir)\e\C-e"' -``` - -### Fish shell +#### Fish shell It's [a known bug of fish](https://github.com/fish-shell/fish-shell/issues/1362) that it doesn't allow reading from STDIN in command substitution, which means @@ -530,42 +366,15 @@ simple `vim (fzf)` won't work as expected. The workaround is to store the result of fzf to a temporary file. ```sh -function vimf - if fzf > $TMPDIR/fzf.result - vim (cat $TMPDIR/fzf.result) - end -end - -function fe - set tmp $TMPDIR/fzf.result - fzf --query="$argv[1]" --select-1 --exit-0 > $tmp - if [ (cat $tmp | wc -l) -gt 0 ] - vim (cat $tmp) - end -end -``` - -### Windows - -fzf works on [Cygwin](http://www.cygwin.com/) and -[MSYS2](http://sourceforge.net/projects/msys2/). You may need to use `--black` -option on MSYS2 to avoid rendering issues. - -### Handling UTF-8 NFD paths on OSX - -Use iconv to convert NFD paths to NFC: - -```sh -find . | iconv -f utf-8-mac -t utf8//ignore | fzf +fzf > $TMPDIR/fzf.result; and vim (cat $TMPDIR/fzf.result) ``` License ------- -MIT +[MIT](LICENSE) Author ------ Junegunn Choi - diff --git a/Rakefile b/Rakefile deleted file mode 100644 index 933a039..0000000 --- a/Rakefile +++ /dev/null @@ -1,9 +0,0 @@ -require "bundler/gem_tasks" -require 'rake/testtask' - -Rake::TestTask.new(:test) do |test| - test.pattern = 'test/**/test_*.rb' - test.verbose = true -end - -task :default => :test diff --git a/bin/fzf-tmux b/bin/fzf-tmux new file mode 100755 index 0000000..239c878 --- /dev/null +++ b/bin/fzf-tmux @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# fzf-tmux: starts fzf in a tmux pane +# usage: fzf-tmux [-u|-d [HEIGHT[%]]] [-l|-r [WIDTH[%]]] [--] [FZF OPTIONS] + +args=() +opt="" +skip="" +swap="" +close="" +term="" +while [ $# -gt 0 ]; do + arg="$1" + case "$arg" in + -) + term=1 + ;; + -w*|-h*|-d*|-u*|-r*|-l*) + if [ -n "$skip" ]; then + args+=("$1") + shift + continue + fi + if [[ "$arg" =~ ^.[lrw] ]]; then + opt="-h" + if [[ "$arg" =~ ^.l ]]; then + opt="$opt -d" + swap="; swap-pane -D ; select-pane -L" + close="; tmux swap-pane -D" + fi + else + opt="" + if [[ "$arg" =~ ^.u ]]; then + opt="$opt -d" + swap="; swap-pane -D ; select-pane -U" + close="; tmux swap-pane -D" + fi + fi + if [ ${#arg} -gt 2 ]; then + size="${arg:2}" + else + shift + if [[ "$1" =~ ^[0-9]+%?$ ]]; then + size="$1" + else + [ -n "$1" -a "$1" != "--" ] && args+=("$1") + shift + continue + fi + fi + + if [[ "$size" =~ %$ ]]; then + size=${size:0:((${#size}-1))} + if [ -n "$swap" ]; then + opt="$opt -p $(( 100 - size ))" + else + opt="$opt -p $size" + fi + else + if [ -n "$swap" ]; then + if [[ "$arg" =~ ^.l ]]; then + [ -n "$COLUMNS" ] && max=$COLUMNS || max=$(tput cols) + else + [ -n "$LINES" ] && max=$LINES || max=$(tput lines) + fi + size=$(( max - size )) + [ $size -lt 0 ] && size=0 + opt="$opt -l $size" + else + opt="$opt -l $size" + fi + fi + ;; + --) + # "--" can be used to separate fzf-tmux options from fzf options to + # avoid conflicts + skip=1 + ;; + *) + args+=("$1") + ;; + esac + shift +done + +if [ -z "$TMUX_PANE" ] || tmux list-panes -F '#F' | grep -q Z; then + fzf "${args[@]}" + exit $? +fi + +set -e + +# Clean up named pipes on exit +id=$RANDOM +argsf=/tmp/fzf-args-$id +fifo1=/tmp/fzf-fifo1-$id +fifo2=/tmp/fzf-fifo2-$id +fifo3=/tmp/fzf-fifo3-$id +cleanup() { + rm -f $argsf $fifo1 $fifo2 $fifo3 +} +trap cleanup EXIT SIGINT SIGTERM + +fail() { + >&2 echo "$1" + exit 2 +} +fzf="$(which fzf 2> /dev/null)" || fzf="$(dirname "$0")/fzf" +[ -x "$fzf" ] || fail "fzf executable not found" + +envs="env TERM=$TERM " +[ -n "$FZF_DEFAULT_OPTS" ] && envs="$envs FZF_DEFAULT_OPTS=$(printf %q "$FZF_DEFAULT_OPTS")" +[ -n "$FZF_DEFAULT_COMMAND" ] && envs="$envs FZF_DEFAULT_COMMAND=$(printf %q "$FZF_DEFAULT_COMMAND")" + +mkfifo $fifo2 +mkfifo $fifo3 + +# Build arguments to fzf +opts="" +for arg in "${args[@]}"; do + arg="${arg//\"/\\\"}" + arg="${arg//\`/\\\`}" + opts="$opts \"$arg\"" +done + +if [ -n "$term" -o -t 0 ]; then + cat <<< "$fzf $opts > $fifo2; echo \$? > $fifo3 $close" > $argsf + tmux set-window-option -q synchronize-panes off \;\ + split-window $opt "cd $(printf %q "$PWD");$envs bash $argsf" $swap +else + mkfifo $fifo1 + cat <<< "$fzf $opts < $fifo1 > $fifo2; echo \$? > $fifo3 $close" > $argsf + tmux set-window-option -q synchronize-panes off \;\ + split-window $opt "$envs bash $argsf" $swap + cat <&0 > $fifo1 & +fi +cat $fifo2 +exit "$(cat $fifo3)" + diff --git a/ext/mkrf_conf.rb b/ext/mkrf_conf.rb deleted file mode 100644 index 8e7bf46..0000000 --- a/ext/mkrf_conf.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'rubygems/dependency_installer' - -if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.1.0') - Gem::DependencyInstaller.new.install 'curses', '~> 1.0' -end - -File.open(File.expand_path('../Rakefile', __FILE__), 'w') do |f| - f.puts 'task :default' -end diff --git a/fzf b/fzf index 3b049b9..17f86b5 100755 --- a/fzf +++ b/fzf @@ -8,6 +8,8 @@ # /_/ /___/_/ Fuzzy finder for your shell # # Version: 0.8.9 (Dec 24, 2014) +# Deprecation alert: +# This script is no longer maintained. Use the new Go version. # # Author: Junegunn Choi # URL: https://github.com/junegunn/fzf @@ -124,6 +126,7 @@ class FZF @reverse = false @prompt = '> ' @shr_mtx = Mutex.new + @expect = false @print_query = false argv = @@ -198,6 +201,17 @@ class FZF when '--no-print-query' then @print_query = false when '-e', '--extended-exact' then @extended = :exact when '+e', '--no-extended-exact' then @extended = nil + when '--expect' + argv.shift + @expect = true + when /^--expect=(.*)$/ + @expect = true + when '--toggle-sort', '--tiebreak', '--color', '--bind', '--history', '--history-size' + argv.shift + when '--tac', '--no-tac', '--sync', '--no-sync', '--hscroll', '--no-hscroll', + '--inline-info', '--no-inline-info', '--read0', '--cycle', /^--bind=(.*)$/, + /^--color=(.*)$/, /^--toggle-sort=(.*)$/, /^--tiebreak=(.*)$/, /^--history(-max)?=(.*)$/ + # XXX else usage 1, "illegal option: #{o}" end @@ -272,10 +286,12 @@ class FZF if loaded if @select1 && len == 1 puts @query if @print_query + puts if @expect burp(empty ? matches.first : matches.first.first) exit 0 elsif @exit0 && len == 0 puts @query if @print_query + puts if @expect exit 0 end end @@ -619,7 +635,8 @@ class FZF def start_reader stream = if @source.tty? - if default_command = ENV['FZF_DEFAULT_COMMAND'] + default_command = ENV['FZF_DEFAULT_COMMAND'] + if default_command && !default_command.empty? IO.popen(default_command) elsif !`which find`.empty? IO.popen("find * -path '*/\\.*' -prune -o -type f -print -o -type l -print 2> /dev/null") @@ -1148,6 +1165,7 @@ class FZF C.close_screen q, selects = geta(:@query, :@selects) @stdout.puts q if @print_query + @stdout.puts if @expect if got if selects.empty? burp got diff --git a/fzf-completion.zsh b/fzf-completion.zsh deleted file mode 100644 index 5e2db28..0000000 --- a/fzf-completion.zsh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/zsh -# ____ ____ -# / __/___ / __/ -# / /_/_ / / /_ -# / __/ / /_/ __/ -# /_/ /___/_/-completion.zsh -# - -# TODO diff --git a/fzf.gemspec b/fzf.gemspec deleted file mode 100644 index 0a6c74d..0000000 --- a/fzf.gemspec +++ /dev/null @@ -1,17 +0,0 @@ -# coding: utf-8 -Gem::Specification.new do |spec| - spec.name = 'fzf' - spec.version = '0.8.4' - spec.authors = ['Junegunn Choi'] - spec.email = ['junegunn.c@gmail.com'] - spec.description = %q{Fuzzy finder for your shell} - spec.summary = %q{Fuzzy finder for your shell} - spec.homepage = 'https://github.com/junegunn/fzf' - spec.license = 'MIT' - - spec.bindir = '.' - spec.files = %w[fzf.gemspec] - spec.executables = 'fzf' - - spec.extensions += ['ext/mkrf_conf.rb'] -end diff --git a/install b/install index d0a016d..9203c98 100755 --- a/install +++ b/install @@ -1,62 +1,178 @@ #!/usr/bin/env bash -cd `dirname $BASH_SOURCE` -fzf_base=`pwd` +[[ "$@" =~ --pre ]] && version=0.10.5 pre=1 || + version=0.10.5 pre=0 -# ruby executable -echo -n "Checking Ruby executable ... " -ruby=`which ruby` -if [ $? -ne 0 ]; then - echo "ruby executable not found!" - exit 1 -fi +cd $(dirname $BASH_SOURCE) +fzf_base=$(pwd) -# System ruby is preferred -system_ruby=/usr/bin/ruby -if [ -x $system_ruby -a $system_ruby != "$ruby" ]; then - $system_ruby --disable-gems -rcurses -e0 2> /dev/null - [ $? -eq 0 ] && ruby=$system_ruby -fi +# If stdin is a tty, we are "interactive". +[ -t 0 ] && interactive=yes -echo "OK ($ruby)" +ask() { + # non-interactive shell: wait for a linefeed + # interactive shell: continue after a single keypress + [ -n "$interactive" ] && read_n='-n 1' || read_n= -# Curses-support -echo -n "Checking Curses support ... " -"$ruby" -rcurses -e0 2> /dev/null -if [ $? -eq 0 ]; then - echo "OK" -else - echo "Not found" - echo "Installing 'curses' gem ... " - if (( EUID )); then - /usr/bin/env gem install curses --user-install - else - /usr/bin/env gem install curses - fi + read -p "$1 ([y]/n) " $read_n -r + echo + [[ ! $REPLY =~ ^[Nn]$ ]] +} + +check_binary() { + echo -n " - Checking fzf executable ... " + local output + output=$("$fzf_base"/bin/fzf --version 2>&1) if [ $? -ne 0 ]; then - echo - echo "Failed to install 'curses' gem." - if [[ $(uname -r) =~ 'ARCH' ]]; then - echo "Make sure that base-devel package group is installed." + echo "Error: $output" + binary_error="Invalid binary" + elif [ "$version" != "$output" ]; then + echo "$output != $version" + binary_error="Invalid version" + else + echo "$output" + binary_error="" + return 0 + fi + rm -f "$fzf_base"/bin/fzf + return 1 +} + +symlink() { + echo " - Creating symlink: bin/$1 -> bin/fzf" + (cd "$fzf_base"/bin && + rm -f fzf && + ln -sf $1 fzf) + if [ $? -ne 0 ]; then + binary_error="Failed to create symlink" + return 1 + fi +} + +download() { + echo "Downloading bin/fzf ..." + if [ $pre = 0 ]; then + if [ -x "$fzf_base"/bin/fzf ]; then + echo " - Already exists" + check_binary && return + elif [ -x "$fzf_base"/bin/$1 ]; then + symlink $1 && check_binary && return fi + fi + mkdir -p "$fzf_base"/bin && cd "$fzf_base"/bin + if [ $? -ne 0 ]; then + binary_error="Failed to create bin directory" + return + fi + + local url=https://github.com/junegunn/fzf-bin/releases/download/$version/${1}.tgz + if which curl > /dev/null; then + curl -fL $url | tar -xz + elif which wget > /dev/null; then + wget -O - $url | tar -xz + else + binary_error="curl or wget not found" + return + fi + + if [ ! -f $1 ]; then + binary_error="Failed to download ${1}" + return + fi + + chmod +x $1 && symlink $1 || return 1 + if [[ $1 =~ linux_amd64$ ]]; then + check_binary || download $1-static + else + check_binary + fi +} + +# Try to download binary executable +archi=$(uname -sm) +binary_available=1 +binary_error="" +case "$archi" in + Darwin\ x86_64) download fzf-$version-darwin_amd64 ;; + Darwin\ i*86) download fzf-$version-darwin_386 ;; + Linux\ x86_64) download fzf-$version-linux_amd64 ;; + Linux\ i*86) download fzf-$version-linux_386 ;; + *) binary_available=0 binary_error=1 ;; +esac + +cd "$fzf_base" +if [ -n "$binary_error" ]; then + if [ $binary_available -eq 0 ]; then + echo "No prebuilt binary for $archi ... " + else + echo " - $binary_error !!!" exit 1 fi -fi + echo "Installing legacy Ruby version ..." -# Ruby version -echo -n "Checking Ruby version ... " -"$ruby" -e 'exit RUBY_VERSION >= "1.9"' -if [ $? -eq 0 ]; then - echo ">= 1.9" - "$ruby" --disable-gems -rcurses -e0 2> /dev/null + # ruby executable + echo -n "Checking Ruby executable ... " + ruby=`which ruby` + if [ $? -ne 0 ]; then + echo "ruby executable not found !!!" + exit 1 + fi + + # System ruby is preferred + system_ruby=/usr/bin/ruby + if [ -x $system_ruby -a $system_ruby != "$ruby" ]; then + $system_ruby --disable-gems -rcurses -e0 2> /dev/null + [ $? -eq 0 ] && ruby=$system_ruby + fi + + echo "OK ($ruby)" + + # Curses-support + echo -n "Checking Curses support ... " + "$ruby" -rcurses -e0 2> /dev/null if [ $? -eq 0 ]; then - fzf_cmd="$ruby --disable-gems $fzf_base/fzf" + echo "OK" else + echo "Not found" + echo "Installing 'curses' gem ... " + if (( EUID )); then + /usr/bin/env gem install curses --user-install + else + /usr/bin/env gem install curses + fi + if [ $? -ne 0 ]; then + echo + echo "Failed to install 'curses' gem." + if [[ $(uname -r) =~ 'ARCH' ]]; then + echo "Make sure that base-devel package group is installed." + fi + exit 1 + fi + fi + + # Ruby version + echo -n "Checking Ruby version ... " + "$ruby" -e 'exit RUBY_VERSION >= "1.9"' + if [ $? -eq 0 ]; then + echo ">= 1.9" + "$ruby" --disable-gems -rcurses -e0 2> /dev/null + if [ $? -eq 0 ]; then + fzf_cmd="$ruby --disable-gems $fzf_base/fzf" + else + fzf_cmd="$ruby $fzf_base/fzf" + fi + else + echo "< 1.9" fzf_cmd="$ruby $fzf_base/fzf" fi -else - echo "< 1.9" - fzf_cmd="$ruby $fzf_base/fzf" + + # Create fzf script + echo -n "Creating wrapper script for fzf ... " + rm -f "$fzf_base"/bin/fzf + echo "#!/bin/sh" > "$fzf_base"/bin/fzf + echo "$fzf_cmd \"\$@\"" >> "$fzf_base"/bin/fzf + chmod +x "$fzf_base"/bin/fzf + echo "OK" fi if [[ $1 == "-y" ]] ; then @@ -65,15 +181,11 @@ if [[ $1 == "-y" ]] ; then key_bindings=0 else # Auto-completion - read -p "Do you want to add auto-completion support? ([y]/n) " -n 1 -r - echo - [[ ! $REPLY =~ ^[Nn]$ ]] + ask "Do you want to add auto-completion support?" auto_completion=$? # Key-bindings - read -p "Do you want to add key bindings? ([y]/n) " -n 1 -r - echo - [[ ! $REPLY =~ ^[Nn]$ ]] + ask "Do you want to add key bindings?" key_bindings=$? fi @@ -82,268 +194,62 @@ for shell in bash zsh; do echo -n "Generate ~/.fzf.$shell ... " src=~/.fzf.${shell} - fzf_completion="[[ \$- =~ i ]] && source $fzf_base/fzf-completion.${shell}" + fzf_completion="[[ \$- =~ i ]] && source \"$fzf_base/shell/completion.${shell}\" 2> /dev/null" if [ $auto_completion -ne 0 ]; then fzf_completion="# $fzf_completion" fi + fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\"" + if [ $key_bindings -ne 0 ]; then + fzf_key_bindings="# $fzf_key_bindings" + fi + cat > $src << EOF -# Setup fzf function -# ------------------ -unalias fzf 2> /dev/null -fzf() { - $fzf_cmd "\$@" -} -export -f fzf > /dev/null +# Setup fzf +# --------- +if [[ ! "\$PATH" =~ "$fzf_base/bin" ]]; then + export PATH="\$PATH:$fzf_base/bin" +fi + +# Man path +# -------- +if [[ ! "\$MANPATH" =~ "$fzf_base/man" && -d "$fzf_base/man" ]]; then + export MANPATH="\$MANPATH:$fzf_base/man" +fi # Auto-completion # --------------- $fzf_completion +# Key bindings +# ------------ +$fzf_key_bindings + EOF - - if [ $key_bindings -eq 0 ]; then - if [ $shell = bash ]; then - cat >> $src << "EOFZF" -# Key bindings -# ------------ -__fsel() { - command find * -path '*/\.*' -prune \ - -o -type f -print \ - -o -type d -print \ - -o -type l -print 2> /dev/null | fzf -m | while read item; do - printf '%q ' "$item" - done - echo -} - -if [[ $- =~ i ]]; then - -__fsel_tmux() { - local height - height=${FZF_TMUX_HEIGHT:-40%} - if [[ $height =~ %$ ]]; then - height="-p ${height%\%}" - else - height="-l $height" - fi - tmux split-window $height "bash -c 'source ~/.fzf.bash; tmux send-keys -t $TMUX_PANE \"\$(__fsel)\"'" -} - -__fcd() { - local dir - dir=$(command find -L ${1:-*} -path '*/\.*' -prune -o -type d -print 2> /dev/null | fzf +m) && printf 'cd %q' "$dir" -} - -__use_tmux=0 -[ -n "$TMUX_PANE" -a ${FZF_TMUX:-1} -ne 0 -a ${LINES:-40} -gt 15 ] && __use_tmux=1 - -if [ -z "$(set -o | \grep '^vi.*on')" ]; then - # Required to refresh the prompt after fzf - bind '"\er": redraw-current-line' - - # CTRL-T - Paste the selected file path into the command line - if [ $__use_tmux -eq 1 ]; then - bind '"\C-t": " \C-u \C-a\C-k$(__fsel_tmux)\e\C-e\C-y\C-a\C-d\C-y\ey\C-h"' - else - bind '"\C-t": " \C-u \C-a\C-k$(__fsel)\e\C-e\C-y\C-a\C-y\ey\C-h\C-e\er \C-h"' - fi - - # CTRL-R - Paste the selected command from history into the command line - bind '"\C-r": " \C-e\C-u$(HISTTIMEFORMAT= history | fzf +s +m -n2..,.. | sed \"s/ *[0-9]* *//\")\e\C-e\er"' - - # ALT-C - cd into the selected directory - bind '"\ec": " \C-e\C-u$(__fcd)\e\C-e\er\C-m"' -else - bind '"\C-x\C-e": shell-expand-line' - bind '"\C-x\C-r": redraw-current-line' - - # CTRL-T - Paste the selected file path into the command line - # - FIXME: Selected items are attached to the end regardless of cursor position - if [ $__use_tmux -eq 1 ]; then - bind '"\C-t": "\e$a \eddi$(__fsel_tmux)\C-x\C-e\e0P$xa"' - else - bind '"\C-t": "\e$a \eddi$(__fsel)\C-x\C-e\e0Px$a \C-x\C-r\exa "' - fi - bind -m vi-command '"\C-t": "i\C-t"' - - # CTRL-R - Paste the selected command from history into the command line - bind '"\C-r": "\eddi$(HISTTIMEFORMAT= history | fzf +s +m -n2..,.. | sed \"s/ *[0-9]* *//\")\C-x\C-e\e$a\C-x\C-r"' - bind -m vi-command '"\C-r": "i\C-r"' - - # ALT-C - cd into the selected directory - bind '"\ec": "\eddi$(__fcd)\C-x\C-e\C-x\C-r\C-m"' - bind -m vi-command '"\ec": "i\ec"' -fi - -unset __use_tmux - -fi -EOFZF - else - cat >> $src << "EOFZF" -# Key bindings -# ------------ -# CTRL-T - Paste the selected file path(s) into the command line -__fsel() { - set -o nonomatch - command find * -path '*/\.*' -prune \ - -o -type f -print \ - -o -type d -print \ - -o -type l -print 2> /dev/null | fzf -m | while read item; do - printf '%q ' "$item" - done - echo -} - -if [[ $- =~ i ]]; then - -if [ -n "$TMUX_PANE" -a ${FZF_TMUX:-1} -ne 0 -a ${LINES:-40} -gt 15 ]; then - fzf-file-widget() { - local height - height=${FZF_TMUX_HEIGHT:-40%} - if [[ $height =~ %$ ]]; then - height="-p ${height%\%}" - else - height="-l $height" - fi - tmux split-window $height "zsh -c 'source ~/.fzf.zsh; tmux send-keys -t $TMUX_PANE \"\$(__fsel)\"'" - } -else - fzf-file-widget() { - LBUFFER="${LBUFFER}$(__fsel)" - zle redisplay - } -fi -zle -N fzf-file-widget -bindkey '^T' fzf-file-widget - -# ALT-C - cd into the selected directory -fzf-cd-widget() { - cd "${$(set -o nonomatch; command find -L * -path '*/\.*' -prune \ - -o -type d -print 2> /dev/null | fzf):-.}" - zle reset-prompt -} -zle -N fzf-cd-widget -bindkey '\ec' fzf-cd-widget - -# CTRL-R - Paste the selected command from history into the command line -fzf-history-widget() { - LBUFFER=$(fc -l 1 | fzf +s +m -n2..,.. | sed "s/ *[0-9*]* *//") - zle redisplay -} -zle -N fzf-history-widget -bindkey '^R' fzf-history-widget - -fi -EOFZF - fi - fi - echo "OK" done # fish has_fish=0 -if [ -n "$(which fish)" ]; then +if [ -n "$(which fish 2> /dev/null)" ]; then has_fish=1 - echo -n "Generate ~/.config/fish/functions/fzf.fish ... " + echo -n "Update fish_user_paths ... " + fish << EOF + echo \$fish_user_paths | grep $fzf_base/bin > /dev/null + or set --universal fish_user_paths \$fish_user_paths $fzf_base/bin +EOF + [ $? -eq 0 ] && echo "OK" || echo "Failed" + mkdir -p ~/.config/fish/functions - cat > ~/.config/fish/functions/fzf.fish << EOFZF -function fzf - $fzf_cmd \$argv -end -EOFZF - echo "OK" + if [ -e ~/.config/fish/functions/fzf.fish ]; then + echo -n "Remove unnecessary ~/.config/fish/functions/fzf.fish ... " + rm -f ~/.config/fish/functions/fzf.fish && echo "OK" || echo "Failed" + fi if [ $key_bindings -eq 0 ]; then - echo -n "Generate ~/.config/fish/functions/fzf_key_bindings.fish ... " - cat > ~/.config/fish/functions/fzf_key_bindings.fish << "EOFZF" -function fzf_key_bindings - # Due to a bug of fish, we cannot use command substitution, - # so we use temporary file instead - if [ -z "$TMPDIR" ] - set -g TMPDIR /tmp - end - - function __fzf_list - command find * -path '*/\.*' -prune \ - -o -type f -print \ - -o -type d -print \ - -o -type l -print 2> /dev/null - end - - function __fzf_list_dir - command find -L * -path '*/\.*' -prune -o -type d -print 2> /dev/null - end - - function __fzf_escape - while read item - echo -n (echo -n "$item" | sed -E 's/([ "$~'\''([{<>})])/\\\\\\1/g')' ' - end - end - - function __fzf_ctrl_t - if [ -n "$TMUX_PANE" -a "$FZF_TMUX" != "0" ] - tmux split-window (__fzf_tmux_height) "fish -c 'fzf_key_bindings; __fzf_ctrl_t_tmux \\$TMUX_PANE'" - else - __fzf_list | fzf -m > $TMPDIR/fzf.result - and commandline -i (cat $TMPDIR/fzf.result | __fzf_escape) - commandline -f repaint - rm -f $TMPDIR/fzf.result - end - end - - function __fzf_ctrl_t_tmux - __fzf_list | fzf -m > $TMPDIR/fzf.result - and tmux send-keys -t $argv[1] (cat $TMPDIR/fzf.result | __fzf_escape) - rm -f $TMPDIR/fzf.result - end - - function __fzf_reverse - if which tac > /dev/null - tac $argv - else - tail -r $argv - end - end - - function __fzf_ctrl_r - history | __fzf_reverse | fzf +s +m > $TMPDIR/fzf.result - and commandline (cat $TMPDIR/fzf.result) - commandline -f repaint - rm -f $TMPDIR/fzf.result - end - - function __fzf_alt_c - # Fish hangs if the command before pipe redirects (2> /dev/null) - __fzf_list_dir | fzf +m > $TMPDIR/fzf.result - [ (cat $TMPDIR/fzf.result | wc -l) -gt 0 ] - and cd (cat $TMPDIR/fzf.result) - commandline -f repaint - rm -f $TMPDIR/fzf.result - end - - function __fzf_tmux_height - if set -q FZF_TMUX_HEIGHT - set height $FZF_TMUX_HEIGHT - else - set height 40% - end - if echo $height | \grep -q -E '%$' - echo "-p "(echo $height | sed 's/%$//') - else - echo "-l $height" - end - set -e height - end - - bind \ct '__fzf_ctrl_t' - bind \cr '__fzf_ctrl_r' - bind \ec '__fzf_alt_c' -end -EOFZF - echo "OK" + echo -n "Symlink ~/.config/fish/functions/fzf_key_bindings.fish ... " + ln -sf $fzf_base/shell/key-bindings.fish \ + ~/.config/fish/functions/fzf_key_bindings.fish && echo "OK" || echo "Failed" fi fi @@ -359,6 +265,7 @@ append_line() { if [ -n "$line" ]; then echo " - Already exists: line #$line" else + echo >> "$2" echo "$1" >> "$2" echo " + Added" fi @@ -373,12 +280,6 @@ done if [ $key_bindings -eq 0 -a $has_fish -eq 1 ]; then bind_file=~/.config/fish/functions/fish_user_key_bindings.fish append_line "fzf_key_bindings" "$bind_file" - - echo ' * Due to a known bug of fish, you may have issues running fzf on fish.' - echo ' * If that happens, try the following:' - echo ' - Remove ~/.config/fish/functions/fzf.fish' - echo ' - Place fzf executable in a directory included in $PATH' - echo fi cat << EOF diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 new file mode 100644 index 0000000..4147371 --- /dev/null +++ b/man/man1/fzf.1 @@ -0,0 +1,413 @@ +.ig +The MIT License (MIT) + +Copyright (c) 2015 Junegunn Choi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +.. +.TH fzf 1 "Sep 2015" "fzf 0.10.5" "fzf - a command-line fuzzy finder" + +.SH NAME +fzf - a command-line fuzzy finder + +.SH SYNOPSIS +fzf [options] + +.SH DESCRIPTION +fzf is a general-purpose command-line fuzzy finder. + +.SH OPTIONS +.SS Search mode +.TP +.B "-x, --extended" +Extended-search mode +.TP +.B "-e, --extended-exact" +Extended-search mode (exact match) +.TP +.B "-i" +Case-insensitive match (default: smart-case match) +.TP +.B "+i" +Case-sensitive match +.TP +.BI "-n, --nth=" "N[,..]" +Comma-separated list of field index expressions for limiting search scope. +See \fBFIELD INDEX EXPRESSION\fR for details. +.TP +.BI "--with-nth=" "N[,..]" +Transform each item using index expressions within finder +.TP +.BI "-d, --delimiter=" "STR" +Field delimiter regex for \fB--nth\fR and \fB--with-nth\fR (default: AWK-style) +.SS Search result +.TP +.B "+s, --no-sort" +Do not sort the result +.TP +.B "--tac" +Reverse the order of the input +.RS +e.g. \fBhistory | fzf --tac --no-sort\fR +.RE +.TP +.BI "--tiebreak=" "CRI" +Sort criterion to use when the scores are tied +.br +.R "" +.br +.BR length " Prefers item with shorter length" +.br +.BR begin " Prefers item with matched substring closer to the beginning" +.br +.BR end " Prefers item with matched substring closer to the end" +.br +.BR index " Prefers item that appeared earlier in the input stream" +.br +.SS Interface +.TP +.B "-m, --multi" +Enable multi-select with tab/shift-tab +.TP +.B "--ansi" +Enable processing of ANSI color codes +.TP +.B "--no-mouse" +Disable mouse +.TP +.BI "--color=" "[BASE_SCHEME][,COLOR:ANSI]" +Color configuration. The name of the base color scheme is followed by custom +color mappings. Ansi color code of -1 denotes terminal default +foreground/background color. + +.RS +e.g. \fBfzf --color=bg+:24\fR + \fBfzf --color=light,fg:232,bg:255,bg+:116,info:27\fR +.RE + +.RS +.B BASE SCHEME: + (default: dark on 256-color terminal, otherwise 16) + + \fBdark \fRColor scheme for dark 256-color terminal + \fBlight \fRColor scheme for light 256-color terminal + \fB16 \fRColor scheme for 16-color terminal + \fBbw \fRNo colors + +.B COLOR: + \fBfg \fRText + \fBbg \fRBackground + \fBhl \fRHighlighted substrings + \fBfg+ \fRText (current line) + \fBbg+ \fRBackground (current line) + \fBhl+ \fRHighlighted substrings (current line) + \fBinfo \fRInfo + \fBprompt \fRPrompt + \fBpointer \fRPointer to the current line + \fBmarker \fRMulti-select marker + \fBspinner \fRStreaming input indicator + \fBheader \fRHeader +.RE +.TP +.B "--black" +Use black background +.TP +.B "--reverse" +Reverse orientation +.TP +.BI "--margin=" MARGIN +Comma-separated expression for margins around the finder. +.br +.R "" +.br +.RS +.BR TRBL " Same margin for top, right, bottom, and left" +.br +.BR TB,RL " Vertical, horizontal margin" +.br +.BR T,RL,B " Top, horizontal, bottom margin" +.br +.BR T,R,B,L " Top, right, bottom, left margin" +.br +.R "" +.br +Each part can be given in absolute number or in percentage relative to the +terminal size with \fB%\fR suffix. +.br +.R "" +.br +e.g. \fBfzf --margin 10%\fR + \fBfzf --margin 1,5%\fR +.RE +.TP +.B "--cycle" +Enable cyclic scroll +.TP +.B "--no-hscroll" +Disable horizontal scroll +.TP +.B "--inline-info" +Display finder info inline with the query +.TP +.BI "--prompt=" "STR" +Input prompt (default: '> ') +.TP +.BI "--toggle-sort=" "KEY" +Key to toggle sort. For the list of the allowed key names, see \fB--bind\fR. +.TP +.BI "--bind=" "KEYBINDS" +Comma-separated list of custom key bindings. Each key binding expression +follows the following format: \fBKEY:ACTION\fR +.RS +e.g. \fBfzf --bind=ctrl-j:accept,ctrl-k:kill-line\fR +.RE + +.RS +.B AVAILABLE KEYS: + \fIctrl-[a-z]\fR + \fIalt-[a-z]\fR + \fIf[1-4]\fR + \fIenter\fR (\fIreturn\fR) + \fIspace\fR + \fIbspace\fR (\fIbs\fR) + \fIalt-bspace\fR (\fIalt-bs\fR) + \fItab\fR + \fIbtab\fR (\fIshift-tab\fR) + \fIesc\fR + \fIdel\fR + \fIup\fR + \fIdown\fR + \fIleft\fR + \fIright\fR + \fIhome\fR + \fIend\fR + \fIpgup\fR (\fIpage-up\fR) + \fIpgdn\fR (\fIpage-down\fR) + \fIshift-left\fR + \fIshift-right\fR + or any single character +.RE + +.RS + \fBACTION: DEFAULT BINDINGS: + \fBabort\fR \fIctrl-c ctrl-g ctrl-q esc\fR + \fBaccept\fR \fIctrl-m (enter)\fR + \fBbackward-char\fR \fIctrl-b left\fR + \fBbackward-delete-char\fR \fIctrl-h bspace\fR + \fBbackward-kill-word\fR \fIalt-bs\fR + \fBbackward-word\fR \fIalt-b shift-left\fR + \fBbeginning-of-line\fR \fIctrl-a home\fR + \fBcancel\fR + \fBclear-screen\fR \fIctrl-l\fR + \fBdelete-char\fR \fIdel\fR + \fBdelete-char/eof\fR \fIctrl-d\fR + \fBdeselect-all\fR + \fBdown\fR \fIctrl-j ctrl-n down\fR + \fBend-of-line\fR \fIctrl-e end\fR + \fBexecute(...)\fR (see below for the details) + \fBforward-char\fR \fIctrl-f right\fR + \fBforward-word\fR \fIalt-f shift-right\fR + \fBignore\fR + \fBkill-line\fR + \fBkill-word\fR \fIalt-d\fR + \fBnext-history\fR (\fIctrl-n\fR on \fB--history\fR) + \fBpage-down\fR \fIpgdn\fR + \fBpage-up\fR \fIpgup\fR + \fBprevious-history\fR (\fIctrl-p\fR on \fB--history\fR) + \fBselect-all\fR + \fBtoggle\fR + \fBtoggle-all\fR + \fBtoggle-down\fR \fIctrl-i (tab)\fR + \fBtoggle-sort\fR (equivalent to \fB--toggle-sort\fR) + \fBtoggle-up\fR \fIbtab (shift-tab)\fR + \fBunix-line-discard\fR \fIctrl-u\fR + \fBunix-word-rubout\fR \fIctrl-w\fR + \fBup\fR \fIctrl-k ctrl-p up\fR + \fByank\fR \fIctrl-y\fR +.RE + +.RS +With \fBexecute(...)\fR action, you can execute arbitrary commands without +leaving fzf. For example, you can turn fzf into a simple file browser by +binding \fBenter\fR key to \fBless\fR command like follows. + +.RS +\fBfzf --bind "enter:execute(less {})"\fR +.RE + +\fB{}\fR is the placeholder for the double-quoted string of the current line. +If the command contains parentheses, you can use any of the following +alternative notations to avoid parse errors. + + \fBexecute[...]\fR + \fBexecute~...~\fR + \fBexecute!...!\fR + \fBexecute@...@\fR + \fBexecute#...#\fR + \fBexecute$...$\fR + \fBexecute%...%\fR + \fBexecute^...^\fR + \fBexecute&...&\fR + \fBexecute*...*\fR + \fBexecute;...;\fR + \fBexecute/.../\fR + \fBexecute|...|\fR + \fBexecute:...\fR +.RS +This is the special form that frees you from parse errors as it does not expect +the closing character. The catch is that it should be the last one in the +comma-separated list. +.RE +.RE +.TP +.BI "--history=" "HISTORY_FILE" +Load search history from the specified file and update the file on completion. +When enabled, \fBCTRL-N\fR and \fBCTRL-P\fR are automatically remapped to +\fBnext-history\fR and \fBprevious-history\fR. +.TP +.BI "--history-size=" "N" +Maximum number of entries in the history file (default: 1000). The file is +automatically truncated when the number of the lines exceeds the value. +.TP +.BI "--header=" "STR" +The given string will be printed as the sticky header. The lines are displayed +in the given order from top to bottom regardless of \fB--reverse\fR option, and +are not affected by \fB--with-nth\fR. ANSI color codes are processed even when +\fB--ansi\fR is not set. +.TP +.BI "--header-lines=" "N" +The first N lines of the input are treated as the sticky header. When +\fB--with-nth\fR is set, the lines are transformed just like the other +lines that follow. +.SS Scripting +.TP +.BI "-q, --query=" "STR" +Start the finder with the given query +.TP +.B "-1, --select-1" +Automatically select the only match +.TP +.B "-0, --exit-0" +Exit immediately when there's no match +.TP +.BI "-f, --filter=" "STR" +Filter mode. Do not start interactive finder. When used with \fB--no-sort\fR, +fzf becomes a fuzzy-version of grep. +.TP +.B "--print-query" +Print query as the first line +.TP +.BI "--expect=" "KEY[,..]" +Comma-separated list of keys that can be used to complete fzf in addition to +the default enter key. When this option is set, fzf will print the name of the +key pressed as the first line of its output (or as the second line if +\fB--print-query\fR is also used). The line will be empty if fzf is completed +with the default enter key. +.RS +e.g. \fBfzf --expect=ctrl-v,ctrl-t,alt-s,f1,f2,~,@\fR +.RE +.TP +.B "--sync" +Synchronous search for multi-staged filtering. If specified, fzf will launch +ncurses finder only after the input stream is complete. +.RS +e.g. \fBfzf --multi | fzf --sync\fR +.RE + +.SH ENVIRONMENT +.TP +.B FZF_DEFAULT_COMMAND +Default command to use when input is tty +.TP +.B FZF_DEFAULT_OPTS +Default options. e.g. \fBexport FZF_DEFAULT_OPTS="--extended --cycle"\fR + +.SH EXIT STATUS +.BR 0 " Normal exit" +.br +.BR 1 " Interrupted with \fBCTRL-C\fR or \fBESC\fR" + +.SH FIELD INDEX EXPRESSION + +A field index expression can be a non-zero integer or a range expression +([BEGIN]..[END]). \fB--nth\fR and \fB--with-nth\fR take a comma-separated list +of field index expressions. + +.SS Examples +.BR 1 " The 1st field" +.br +.BR 2 " The 2nd field" +.br +.BR -1 " The last field" +.br +.BR -2 " The 2nd to last field" +.br +.BR 3..5 " From the 3rd field to the 5th field" +.br +.BR 2.. " From the 2nd field to the last field" +.br +.BR ..-3 " From the 1st field to the 3rd to the last field" +.br +.BR .. " All the fields" +.br + +.SH EXTENDED SEARCH MODE + +With \fB-x\fR or \fB--extended\fR option, fzf will start in "extended-search +mode". In this mode, you can specify multiple patterns delimited by spaces, +such as: \fB'wild ^music .mp3$ sbtrkt !rmx\fR + +.SS Exact-match (quoted) +A term that is prefixed by a single-quote character (\fB'\fR) is interpreted as +an "exact-match" (or "non-fuzzy") term. fzf will search for the exact +occurrences of the string. + +.SS Anchored-match +A term can be prefixed by \fB^\fR, or suffixed by \fB$\fR to become an +anchored-match term. Then fzf will search for the items that start with or end +with the given string. An anchored-match term is also an exact-match term. + +.SS Negation +If a term is prefixed by \fB!\fR, fzf will exclude the items that satisfy the +term from the result. + +.SS Extended-exact mode +If you don't prefer fuzzy matching and do not wish to "quote" (prefixing with +\fB'\fR) every word, start fzf with \fB-e\fR or \fB--extended-exact\fR option +(instead of \fB-x\fR or \fB--extended\fR). Note that in \fB--extended-exact\fR +mode, \fB'\fR-prefix "unquotes" the term. + +.SH AUTHOR +Junegunn Choi (\fIjunegunn.c@gmail.com\fR) + +.SH SEE ALSO +.B Project homepage: +.RS +.I https://github.com/junegunn/fzf +.RE +.br +.R "" +.br +.B Extra Vim plugin: +.RS +.I https://github.com/junegunn/fzf.vim +.RE + +.SH LICENSE +MIT diff --git a/plugin/fzf.vim b/plugin/fzf.vim index db3c649..d9ced8e 100644 --- a/plugin/fzf.vim +++ b/plugin/fzf.vim @@ -1,4 +1,4 @@ -" Copyright (c) 2014 Junegunn Choi +" Copyright (c) 2015 Junegunn Choi " " MIT License " @@ -21,30 +21,41 @@ " OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION " WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -let s:min_tmux_width = 10 -let s:min_tmux_height = 3 -let s:default_tmux_height = '40%' -let s:launcher = 'xterm -e bash -ic %s' -let s:fzf_rb = expand(':h:h').'/fzf' +let s:default_height = '40%' +let s:fzf_go = expand(':h:h').'/bin/fzf' +let s:install = expand(':h:h').'/install' +let s:installed = 0 +let s:fzf_tmux = expand(':h:h').'/bin/fzf-tmux' let s:cpo_save = &cpo set cpo&vim function! s:fzf_exec() if !exists('s:exec') - call system('type fzf') - if v:shell_error - let s:exec = executable(s:fzf_rb) ? s:fzf_rb : '' - else + if executable(s:fzf_go) + let s:exec = s:fzf_go + elseif executable('fzf') let s:exec = 'fzf' + elseif !s:installed && executable(s:install) && + \ input('fzf executable not found. Download binary? (y/n) ') =~? '^y' + redraw + echo + echohl WarningMsg + echo 'Downloading fzf binary. Please wait ...' + echohl None + let s:installed = 1 + call system(s:install.' --bin') + return s:fzf_exec() + else + redraw + throw 'fzf executable not found' endif - return s:fzf_exec() - elseif empty(s:exec) - unlet s:exec - throw 'fzf executable not found' - else - return s:exec endif + return s:exec +endfunction + +function! s:tmux_not_zoomed() + return system('tmux list-panes -F "#F"') !~# 'Z' endfunction function! s:tmux_enabled() @@ -53,15 +64,15 @@ function! s:tmux_enabled() endif if exists('s:tmux') - return s:tmux + return s:tmux && s:tmux_not_zoomed() endif let s:tmux = 0 - if exists('$TMUX') + if exists('$TMUX') && executable(s:fzf_tmux) let output = system('tmux -V') let s:tmux = !v:shell_error && output >= 'tmux 1.7' endif - return s:tmux + return s:tmux && s:tmux_not_zoomed() endfunction function! s:shellesc(arg) @@ -69,11 +80,35 @@ function! s:shellesc(arg) endfunction function! s:escape(path) - return substitute(a:path, ' ', '\\ ', 'g') + return escape(a:path, ' %#''"\') +endfunction + +" Upgrade legacy options +function! s:upgrade(dict) + let copy = copy(a:dict) + if has_key(copy, 'tmux') + let copy.down = remove(copy, 'tmux') + endif + if has_key(copy, 'tmux_height') + let copy.down = remove(copy, 'tmux_height') + endif + if has_key(copy, 'tmux_width') + let copy.right = remove(copy, 'tmux_width') + endif + return copy endfunction function! fzf#run(...) abort - let dict = exists('a:1') ? a:1 : {} +try + let oshell = &shell + set shell=sh + if has('nvim') && bufexists('term://*:FZF') + echohl WarningMsg + echomsg 'FZF is already running!' + echohl None + return [] + endif + let dict = exists('a:1') ? s:upgrade(a:1) : {} let temps = { 'result': tempname() } let optstr = get(dict, 'options', '') try @@ -97,26 +132,68 @@ function! fzf#run(...) abort else let prefix = '' endif - let command = prefix.fzf_exec.' '.optstr.' > '.temps.result + let tmux = !has('nvim') && s:tmux_enabled() && s:splittable(dict) + let command = prefix.(tmux ? s:fzf_tmux(dict) : fzf_exec).' '.optstr.' > '.temps.result - if s:tmux_enabled() && s:tmux_splittable(dict) - return s:execute_tmux(dict, command, temps) - else - return s:execute(dict, command, temps) - endif + try + if tmux + return s:execute_tmux(dict, command, temps) + elseif has('nvim') + return s:execute_term(dict, command, temps) + else + return s:execute(dict, command, temps) + endif + finally + call s:popd(dict) + endtry +finally + let &shell = oshell +endtry endfunction -function! s:tmux_splittable(dict) - return - \ min([&columns, get(a:dict, 'tmux_width', 0)]) >= s:min_tmux_width || - \ min([&lines, get(a:dict, 'tmux_height', get(a:dict, 'tmux', 0))]) >= s:min_tmux_height +function! s:present(dict, ...) + for key in a:000 + if !empty(get(a:dict, key, '')) + return 1 + endif + endfor + return 0 +endfunction + +function! s:fzf_tmux(dict) + let size = '' + for o in ['up', 'down', 'left', 'right'] + if s:present(a:dict, o) + let spec = a:dict[o] + if (o == 'up' || o == 'down') && spec[0] == '~' + let size = '-'.o[0].s:calc_size(&lines, spec[1:], a:dict) + else + " Legacy boolean option + let size = '-'.o[0].(spec == 1 ? '' : spec) + endif + break + endif + endfor + return printf('LINES=%d COLUMNS=%d %s %s %s --', + \ &lines, &columns, s:fzf_tmux, size, (has_key(a:dict, 'source') ? '' : '-')) +endfunction + +function! s:splittable(dict) + return s:present(a:dict, 'up', 'down', 'left', 'right') endfunction function! s:pushd(dict) - if !empty(get(a:dict, 'dir', '')) - let a:dict.prev_dir = getcwd() + if s:present(a:dict, 'dir') + let cwd = getcwd() + if get(a:dict, 'prev_dir', '') ==# cwd + return 1 + endif + let a:dict.prev_dir = cwd execute 'chdir '.s:escape(a:dict.dir) + let a:dict.dir = getcwd() + return 1 endif + return 0 endfunction function! s:popd(dict) @@ -125,124 +202,218 @@ function! s:popd(dict) endif endfunction +function! s:xterm_launcher() + let fmt = 'xterm -T "[fzf]" -bg "\%s" -fg "\%s" -geometry %dx%d+%d+%d -e bash -ic %%s' + if has('gui_macvim') + let fmt .= '; osascript -e "tell application \"MacVim\" to activate"' + endif + return printf(fmt, + \ synIDattr(hlID("Normal"), "bg"), synIDattr(hlID("Normal"), "fg"), + \ &columns, &lines/2, getwinposx(), getwinposy()) +endfunction +unlet! s:launcher +let s:launcher = function('s:xterm_launcher') + function! s:execute(dict, command, temps) call s:pushd(a:dict) - silent !clear + silent! !clear 2> /dev/null + let escaped = escape(substitute(a:command, '\n', '\\n', 'g'), '%#') if has('gui_running') - let launcher = get(a:dict, 'launcher', get(g:, 'fzf_launcher', s:launcher)) - let command = printf(launcher, "'".substitute(a:command, "'", "'\"'\"'", 'g')."'") + let Launcher = get(a:dict, 'launcher', get(g:, 'Fzf_launcher', get(g:, 'fzf_launcher', s:launcher))) + let fmt = type(Launcher) == 2 ? call(Launcher, []) : Launcher + let command = printf(fmt, "'".substitute(escaped, "'", "'\"'\"'", 'g')."'") else - let command = a:command + let command = escaped endif execute 'silent !'.command redraw! if v:shell_error - " Do not print error message on exit status 1 - if v:shell_error > 1 + " Do not print error message on exit status 1 (no match) or 130 (interrupt) + if v:shell_error == 2 echohl ErrorMsg echo 'Error running ' . command + echohl None endif return [] else - return s:callback(a:dict, a:temps, 0) - endif -endfunction - -function! s:env_var(name) - if exists('$'.a:name) - return a:name . "='". substitute(expand('$'.a:name), "'", "'\\\\''", 'g') . "' " - else - return '' + return s:callback(a:dict, a:temps) endif endfunction function! s:execute_tmux(dict, command, temps) - let command = s:env_var('FZF_DEFAULT_OPTS').s:env_var('FZF_DEFAULT_COMMAND').a:command - if !empty(get(a:dict, 'dir', '')) + let command = a:command + if s:pushd(a:dict) + " -c '#{pane_current_path}' is only available on tmux 1.9 or above let command = 'cd '.s:escape(a:dict.dir).' && '.command endif - let splitopt = '-v' - if has_key(a:dict, 'tmux_width') - let splitopt = '-h' - let size = a:dict.tmux_width - else - let size = get(a:dict, 'tmux_height', get(a:dict, 'tmux')) - endif - - if type(size) == 1 && size =~ '%$' - let sizeopt = '-p '.size[0:-2] - else - let sizeopt = '-l '.size - endif - - let s:pane = substitute( - \ system( - \ printf( - \ 'tmux split-window %s %s -P -F "#{pane_id}" %s', - \ splitopt, sizeopt, s:shellesc(command))), '\n', '', 'g') - let s:dict = a:dict - let s:temps = a:temps - - augroup fzf_tmux - autocmd! - autocmd VimResized * nested call s:tmux_check() - augroup END + call system(command) + redraw! + return s:callback(a:dict, a:temps) endfunction -function! s:tmux_check() - let panes = split(system('tmux list-panes -a -F "#{pane_id}"'), '\n') - - if index(panes, s:pane) < 0 - augroup fzf_tmux - autocmd! - augroup END - - call s:callback(s:dict, s:temps, 1) - redraw +function! s:calc_size(max, val, dict) + if a:val =~ '%$' + let size = a:max * str2nr(a:val[:-2]) / 100 + else + let size = min([a:max, str2nr(a:val)]) endif + + let srcsz = -1 + if type(get(a:dict, 'source', 0)) == type([]) + let srcsz = len(a:dict.source) + endif + + let opts = get(a:dict, 'options', '').$FZF_DEFAULT_OPTS + let margin = stridx(opts, '--inline-info') > stridx(opts, '--no-inline-info') ? 1 : 2 + return srcsz >= 0 ? min([srcsz + margin, size]) : size endfunction -function! s:callback(dict, temps, cd) +function! s:getpos() + return {'tab': tabpagenr(), 'win': winnr(), 'cnt': winnr('$')} +endfunction + +function! s:split(dict) + let directions = { + \ 'up': ['topleft', 'resize', &lines], + \ 'down': ['botright', 'resize', &lines], + \ 'left': ['vertical topleft', 'vertical resize', &columns], + \ 'right': ['vertical botright', 'vertical resize', &columns] } + let s:ppos = s:getpos() + try + for [dir, triple] in items(directions) + let val = get(a:dict, dir, '') + if !empty(val) + let [cmd, resz, max] = triple + if (dir == 'up' || dir == 'down') && val[0] == '~' + let sz = s:calc_size(max, val[1:], a:dict) + else + let sz = s:calc_size(max, val, {}) + endif + execute cmd sz.'new' + execute resz sz + return + endif + endfor + if s:present(a:dict, 'window') + execute a:dict.window + else + tabnew + endif + finally + setlocal winfixwidth winfixheight buftype=nofile bufhidden=wipe nobuflisted + endtry +endfunction + +function! s:execute_term(dict, command, temps) + call s:split(a:dict) + call s:pushd(a:dict) + + let fzf = { 'buf': bufnr('%'), 'dict': a:dict, 'temps': a:temps, 'name': 'FZF' } + function! fzf.on_exit(id, code) + let pos = s:getpos() + let inplace = pos == s:ppos " {'window': 'enew'} + if !inplace + if bufnr('') == self.buf + " We use close instead of bd! since Vim does not close the split when + " there's no other listed buffer (nvim +'set nobuflisted') + close + endif + if pos.tab == s:ppos.tab + wincmd p + endif + endif + call s:pushd(self.dict) + try + redraw! + call s:callback(self.dict, self.temps) + + if inplace && bufnr('') == self.buf + execute "normal! \" + " No other listed buffer + if bufnr('') == self.buf + bd! + endif + endif + finally + call s:popd(self.dict) + endtry + endfunction + + call termopen(a:command, fzf) + startinsert + return [] +endfunction + +function! s:callback(dict, temps) +try if !filereadable(a:temps.result) let lines = [] else - if a:cd | call s:pushd(a:dict) | endif - let lines = readfile(a:temps.result) if has_key(a:dict, 'sink') for line in lines if type(a:dict.sink) == 2 call a:dict.sink(line) else - execute a:dict.sink.' '.s:escape(line) + execute a:dict.sink s:escape(line) endif endfor endif + if has_key(a:dict, 'sink*') + call a:dict['sink*'](lines) + endif endif for tf in values(a:temps) silent! call delete(tf) endfor - call s:popd(a:dict) - return lines +catch + if stridx(v:exception, ':E325:') < 0 + echoerr v:exception + endif +endtry +endfunction + +let s:default_action = { + \ 'ctrl-m': 'e', + \ 'ctrl-t': 'tab split', + \ 'ctrl-x': 'split', + \ 'ctrl-v': 'vsplit' } + +function! s:cmd_callback(lines) abort + if empty(a:lines) + return + endif + let key = remove(a:lines, 0) + let cmd = get(s:action, key, 'e') + try + let autochdir = &autochdir + set noautochdir + for item in a:lines + execute cmd s:escape(item) + endfor + finally + let &autochdir = autochdir + endtry endfunction function! s:cmd(bang, ...) abort - let args = copy(a:000) + let s:action = get(g:, 'fzf_action', s:default_action) + let args = extend(['--expect='.join(keys(s:action), ',')], a:000) let opts = {} if len(args) > 0 && isdirectory(expand(args[-1])) - let opts.dir = remove(args, -1) + let opts.dir = substitute(remove(args, -1), '\\\(["'']\)', '\1', 'g') endif if !a:bang - let opts.tmux = get(g:, 'fzf_tmux_height', s:default_tmux_height) + let opts.down = get(g:, 'fzf_height', get(g:, 'fzf_tmux_height', s:default_height)) endif - call fzf#run(extend({ 'sink': 'e', 'options': join(args) }, opts)) + call fzf#run(extend({'options': join(args), 'sink*': function('cmd_callback')}, opts)) endfunction -command! -nargs=* -complete=dir -bang FZF call s:cmd('' == '!', ) +command! -nargs=* -complete=dir -bang FZF call s:cmd(0, ) let &cpo = s:cpo_save unlet s:cpo_save diff --git a/fzf-completion.bash b/shell/completion.bash similarity index 71% rename from fzf-completion.bash rename to shell/completion.bash index 61797b1..d7fdf26 100644 --- a/fzf-completion.bash +++ b/shell/completion.bash @@ -5,6 +5,8 @@ # / __/ / /_/ __/ # /_/ /___/_/-completion.bash # +# - $FZF_TMUX (default: 1) +# - $FZF_TMUX_HEIGHT (default: '40%') # - $FZF_COMPLETION_TRIGGER (default: '**') # - $FZF_COMPLETION_OPTS (default: empty) @@ -24,23 +26,44 @@ _fzf_opts_completion() { -i +i -n --nth -d --delimiter - -s --sort +s + +s --no-sort + --tac + --tiebreak + --bind -m --multi --no-mouse - +c --no-color - +2 --no-256 + --color --black --reverse + --no-hscroll + --inline-info --prompt -q --query -1 --select-1 -0 --exit-0 -f --filter - --print-query" + --print-query + --expect + --toggle-sort + --sync + --cycle + --history + --history-size + --header + --header-lines + --margin" case "${prev}" in - --sort|-s) - COMPREPLY=( $(compgen -W "$(seq 2000 1000 10000)" -- ${cur}) ) + --tiebreak) + COMPREPLY=( $(compgen -W "length begin end index" -- ${cur}) ) + return 0 + ;; + --color) + COMPREPLY=( $(compgen -W "dark light 16 bw" -- ${cur}) ) + return 0 + ;; + --history) + COMPREPLY=() return 0 ;; esac @@ -54,9 +77,10 @@ _fzf_opts_completion() { } _fzf_handle_dynamic_completion() { - local cmd orig ret + local cmd orig ret orig_cmd cmd="$1" shift + orig_cmd="$1" orig=$(eval "echo \$_fzf_orig_completion_$cmd") if [ -n "$orig" ] && type "$orig" > /dev/null 2>&1; then @@ -64,17 +88,18 @@ _fzf_handle_dynamic_completion() { elif [ -n "$_fzf_completion_loader" ]; then _completion_loader "$@" ret=$? - eval $(complete | \grep "\-F.* $cmd$" | _fzf_orig_completion_filter) + eval $(complete | \grep "\-F.* $orig_cmd$" | _fzf_orig_completion_filter) source $BASH_SOURCE return $ret fi } _fzf_path_completion() { - local cur base dir leftover matches trigger cmd + local cur base dir leftover matches trigger cmd fzf + [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" cmd=$(echo ${COMP_WORDS[0]} | sed 's/[^a-z0-9_=]/_/g') COMPREPLY=() - trigger=${FZF_COMPLETION_TRIGGER:-**} + trigger=${FZF_COMPLETION_TRIGGER-'**'} cur="${COMP_WORDS[COMP_CWORD]}" if [[ ${cur} == *"$trigger" ]]; then base=${cur:0:${#cur}-${#trigger}} @@ -87,8 +112,8 @@ _fzf_path_completion() { leftover=${leftover/#\/} [ "$dir" = './' ] && dir='' tput sc - matches=$(find -L "$dir"* $1 2> /dev/null | fzf $FZF_COMPLETION_OPTS $2 -q "$leftover" | while read item; do - printf '%q ' "$item" + matches=$(find -L "$dir"* $1 2> /dev/null | $fzf $FZF_COMPLETION_OPTS $2 -q "$leftover" | while read item; do + printf "%q$3 " "$item" done) matches=${matches% } if [ -n "$matches" ]; then @@ -103,6 +128,7 @@ _fzf_path_completion() { [[ "$dir" =~ /$ ]] || dir="$dir"/ done else + shift shift shift _fzf_handle_dynamic_completion "$cmd" "$@" @@ -110,16 +136,17 @@ _fzf_path_completion() { } _fzf_list_completion() { - local cur selected trigger cmd src + local cur selected trigger cmd src fzf + [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" read -r src cmd=$(echo ${COMP_WORDS[0]} | sed 's/[^a-z0-9_=]/_/g') - trigger=${FZF_COMPLETION_TRIGGER:-**} + trigger=${FZF_COMPLETION_TRIGGER-'**'} cur="${COMP_WORDS[COMP_CWORD]}" if [[ ${cur} == *"$trigger" ]]; then cur=${cur:0:${#cur}-${#trigger}} tput sc - selected=$(eval "$src | fzf $FZF_COMPLETION_OPTS $1 -q '$cur'" | tr '\n' ' ') + selected=$(eval "$src | $fzf $FZF_COMPLETION_OPTS $1 -q '$cur'" | tr '\n' ' ') selected=${selected% } tput rc @@ -136,27 +163,28 @@ _fzf_list_completion() { _fzf_all_completion() { _fzf_path_completion \ "-name .git -prune -o -name .svn -prune -o -type d -print -o -type f -print -o -type l -print" \ - "-m" "$@" + "-m" "" "$@" } _fzf_file_completion() { _fzf_path_completion \ "-name .git -prune -o -name .svn -prune -o -type f -print -o -type l -print" \ - "-m" "$@" + "-m" "" "$@" } _fzf_dir_completion() { _fzf_path_completion \ "-name .git -prune -o -name .svn -prune -o -type d -print" \ - "" "$@" + "" "/" "$@" } _fzf_kill_completion() { [ -n "${COMP_WORDS[COMP_CWORD]}" ] && return 1 - local selected + local selected fzf + [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" tput sc - selected=$(ps -ef | sed 1d | fzf -m $FZF_COMPLETION_OPTS | awk '{print $2}' | tr '\n' ' ') + selected=$(ps -ef | sed 1d | $fzf -m $FZF_COMPLETION_OPTS | awk '{print $2}' | tr '\n' ' ') tput rc if [ -n "$selected" ]; then @@ -167,13 +195,13 @@ _fzf_kill_completion() { _fzf_telnet_completion() { _fzf_list_completion '+m' "$@" << "EOF" - \grep -v '^\s*\(#\|$\)' /etc/hosts | awk '{if (length($2) > 0) {print $2}}' | sort -u + \grep -v '^\s*\(#\|$\)' /etc/hosts | \grep -Fv '0.0.0.0' | awk '{if (length($2) > 0) {print $2}}' | sort -u EOF } _fzf_ssh_completion() { _fzf_list_completion '+m' "$@" << "EOF" - cat <(cat ~/.ssh/config /etc/ssh/ssh_config 2> /dev/null | \grep -i ^host | \grep -v '*') <(\grep -v '^\s*\(#\|$\)' /etc/hosts) | awk '{print $2}' | sort -u + cat <(cat ~/.ssh/config /etc/ssh/ssh_config 2> /dev/null | \grep -i '^host' | \grep -v '*') <(\grep -v '^\s*\(#\|$\)' /etc/hosts | \grep -Fv '0.0.0.0') | awk '{if (length($2) > 0) {print $2}}' | sort -u EOF } @@ -190,7 +218,7 @@ EOF } # fzf options -complete -F _fzf_opts_completion fzf +complete -o default -F _fzf_opts_completion fzf d_cmds="cd pushd rmdir" f_cmds=" @@ -206,11 +234,11 @@ a_cmds=" x_cmds="kill ssh telnet unset unalias export" # Preserve existing completion -if [ "$_fzf_completion_loaded" != '0.8.6-1' ]; then +if [ "$_fzf_completion_loaded" != '0.9.12' ]; then # Really wish I could use associative array but OSX comes with bash 3.2 :( eval $(complete | \grep '\-F' | \grep -v _fzf_ | \grep -E " ($(echo $d_cmds $f_cmds $a_cmds $x_cmds | sed 's/ /|/g' | sed 's/+/\\+/g'))$" | _fzf_orig_completion_filter) - export _fzf_completion_loaded=0.8.6-1 + export _fzf_completion_loaded=0.9.12 fi if type _completion_loader > /dev/null 2>&1; then @@ -219,7 +247,7 @@ fi # Directory for cmd in $d_cmds; do - complete -F _fzf_dir_completion -o default -o bashdefault $cmd + complete -F _fzf_dir_completion -o nospace -o plusdirs $cmd done # File diff --git a/shell/completion.zsh b/shell/completion.zsh new file mode 100644 index 0000000..f45c538 --- /dev/null +++ b/shell/completion.zsh @@ -0,0 +1,163 @@ +#!/bin/zsh +# ____ ____ +# / __/___ / __/ +# / /_/_ / / /_ +# / __/ / /_/ __/ +# /_/ /___/_/-completion.zsh +# +# - $FZF_TMUX (default: 1) +# - $FZF_TMUX_HEIGHT (default: '40%') +# - $FZF_COMPLETION_TRIGGER (default: '**') +# - $FZF_COMPLETION_OPTS (default: empty) + +_fzf_path_completion() { + local base lbuf find_opts fzf_opts suffix tail fzf dir leftover matches nnm + base=${(Q)1} + lbuf=$2 + find_opts=$3 + fzf_opts=$4 + suffix=$5 + tail=$6 + [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + + if ! setopt | grep nonomatch > /dev/null; then + nnm=1 + setopt nonomatch + fi + dir="$base" + while [ 1 ]; do + if [ -z "$dir" -o -d ${~dir} ]; then + leftover=${base/#"$dir"} + leftover=${leftover/#\/} + [ "$dir" = './' ] && dir='' + dir=${~dir} + matches=$(\find -L $dir* ${=find_opts} 2> /dev/null | ${=fzf} ${=FZF_COMPLETION_OPTS} ${=fzf_opts} -q "$leftover" | while read item; do + printf "%q$suffix " "$item" + done) + matches=${matches% } + if [ -n "$matches" ]; then + LBUFFER="$lbuf$matches$tail" + fi + zle redisplay + break + fi + dir=$(dirname "$dir") + dir=${dir%/}/ + done + [ -n "$nnm" ] && unsetopt nonomatch +} + +_fzf_all_completion() { + _fzf_path_completion "$1" "$2" \ + "-name .git -prune -o -name .svn -prune -o -type d -print -o -type f -print -o -type l -print" \ + "-m" "" " " +} + +_fzf_dir_completion() { + _fzf_path_completion "$1" "$2" \ + "-name .git -prune -o -name .svn -prune -o -type d -print" \ + "" "/" "" +} + +_fzf_list_completion() { + local prefix lbuf fzf_opts src fzf matches + prefix=$1 + lbuf=$2 + fzf_opts=$3 + read -r src + [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + + matches=$(eval "$src" | ${=fzf} ${=FZF_COMPLETION_OPTS} ${=fzf_opts} -q "$prefix") + if [ -n "$matches" ]; then + LBUFFER="$lbuf$matches " + fi + zle redisplay +} + +_fzf_telnet_completion() { + _fzf_list_completion "$1" "$2" '+m' << "EOF" + \grep -v '^\s*\(#\|$\)' /etc/hosts | \grep -Fv '0.0.0.0' | awk '{if (length($2) > 0) {print $2}}' | sort -u +EOF +} + +_fzf_ssh_completion() { + _fzf_list_completion "$1" "$2" '+m' << "EOF" + cat <(cat ~/.ssh/config /etc/ssh/ssh_config 2> /dev/null | \grep -i '^host' | \grep -v '*') <(\grep -v '^\s*\(#\|$\)' /etc/hosts | \grep -Fv '0.0.0.0') | awk '{if (length($2) > 0) {print $2}}' | sort -u +EOF +} + +_fzf_env_var_completion() { + _fzf_list_completion "$1" "$2" '+m' << "EOF" + declare -xp | sed 's/=.*//' | sed 's/.* //' +EOF +} + +_fzf_alias_completion() { + _fzf_list_completion "$1" "$2" '+m' << "EOF" + alias | sed 's/=.*//' +EOF +} + +fzf-completion() { + local tokens cmd prefix trigger tail fzf matches lbuf d_cmds sws + if setopt | grep shwordsplit > /dev/null; then + sws=1 + unsetopt shwordsplit + fi + + # http://zsh.sourceforge.net/FAQ/zshfaq03.html + # http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion-Flags + tokens=(${(z)LBUFFER}) + if [ ${#tokens} -lt 1 ]; then + eval "zle ${fzf_default_completion:-expand-or-complete}" + return + fi + + cmd=${tokens[1]} + + # Explicitly allow for empty trigger. + trigger=${FZF_COMPLETION_TRIGGER-'**'} + [ -z "$trigger" -a ${LBUFFER[-1]} = ' ' ] && tokens+=("") + + tail=${LBUFFER:$(( ${#LBUFFER} - ${#trigger} ))} + # Kill completion (do not require trigger sequence) + if [ $cmd = kill -a ${LBUFFER[-1]} = ' ' ]; then + [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + matches=$(ps -ef | sed 1d | ${=fzf} ${=FZF_COMPLETION_OPTS} -m | awk '{print $2}' | tr '\n' ' ') + if [ -n "$matches" ]; then + LBUFFER="$LBUFFER$matches" + fi + zle redisplay + # Trigger sequence given + elif [ ${#tokens} -gt 1 -a "$tail" = "$trigger" ]; then + d_cmds=(cd pushd rmdir) + + [ -z "$trigger" ] && prefix=${tokens[-1]} || prefix=${tokens[-1]:0:-${#trigger}} + [ -z "${tokens[-1]}" ] && lbuf=$LBUFFER || lbuf=${LBUFFER:0:-${#tokens[-1]}} + + if [ ${d_cmds[(i)$cmd]} -le ${#d_cmds} ]; then + _fzf_dir_completion "$prefix" $lbuf + elif [ $cmd = telnet ]; then + _fzf_telnet_completion "$prefix" $lbuf + elif [ $cmd = ssh ]; then + _fzf_ssh_completion "$prefix" $lbuf + elif [ $cmd = unset -o $cmd = export ]; then + _fzf_env_var_completion "$prefix" $lbuf + elif [ $cmd = unalias ]; then + _fzf_alias_completion "$prefix" $lbuf + else + _fzf_all_completion "$prefix" $lbuf + fi + # Fall back to default completion + else + eval "zle ${fzf_default_completion:-expand-or-complete}" + fi + [ -n "$sws" ] && setopt shwordsplit +} + +[ -z "$fzf_default_completion" ] && + fzf_default_completion=$(bindkey '^I' | grep -v undefined-key | awk '{print $2}') + +zle -N fzf-completion +bindkey '^I' fzf-completion + diff --git a/shell/key-bindings.bash b/shell/key-bindings.bash new file mode 100644 index 0000000..0bfd50d --- /dev/null +++ b/shell/key-bindings.bash @@ -0,0 +1,96 @@ +# Key bindings +# ------------ +__fzf_select__() { + local cmd="${FZF_CTRL_T_COMMAND:-"command \\find -L . \\( -path '*/\\.*' -o -fstype 'dev' -o -fstype 'proc' \\) -prune \ + -o -type f -print \ + -o -type d -print \ + -o -type l -print 2> /dev/null | sed 1d | cut -b3-"}" + eval "$cmd" | fzf -m | while read item; do + printf '%q ' "$item" + done + echo +} + +if [[ $- =~ i ]]; then + +__fzfcmd() { + [ ${FZF_TMUX:-1} -eq 1 ] && echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || echo "fzf" +} + +__fzf_select_tmux__() { + local height + height=${FZF_TMUX_HEIGHT:-40%} + if [[ $height =~ %$ ]]; then + height="-p ${height%\%}" + else + height="-l $height" + fi + tmux split-window $height "cd $(printf %q "$PWD"); FZF_CTRL_T_COMMAND=$(printf %q "$FZF_CTRL_T_COMMAND") bash -c 'source ~/.fzf.bash; tmux send-keys -t $TMUX_PANE \"\$(__fzf_select__)\"'" +} + +__fzf_cd__() { + local dir + dir=$(command \find -L ${1:-.} \( -path '*/\.*' -o -fstype 'dev' -o -fstype 'proc' \) -prune \ + -o -type d -print 2> /dev/null | sed 1d | cut -b3- | $(__fzfcmd) +m) && printf 'cd %q' "$dir" +} + +__fzf_history__() ( + local line + shopt -u nocaseglob nocasematch + line=$( + HISTTIMEFORMAT= history | + $(__fzfcmd) +s --tac +m -n2..,.. --tiebreak=index --toggle-sort=ctrl-r | + \grep '^ *[0-9]') && + if [[ $- =~ H ]]; then + sed 's/^ *\([0-9]*\)\** .*/!\1/' <<< "$line" + else + sed 's/^ *\([0-9]*\)\** *//' <<< "$line" + fi +) + +__use_tmux=0 +[ -n "$TMUX_PANE" -a ${FZF_TMUX:-1} -ne 0 -a ${LINES:-40} -gt 15 ] && __use_tmux=1 + +if [ -z "$(set -o | \grep '^vi.*on')" ]; then + # Required to refresh the prompt after fzf + bind '"\er": redraw-current-line' + bind '"\e^": history-expand-line' + + # CTRL-T - Paste the selected file path into the command line + if [ $__use_tmux -eq 1 ]; then + bind '"\C-t": " \C-u \C-a\C-k$(__fzf_select_tmux__)\e\C-e\C-y\C-a\C-d\C-y\ey\C-h"' + else + bind '"\C-t": " \C-u \C-a\C-k$(__fzf_select__)\e\C-e\C-y\C-a\C-y\ey\C-h\C-e\er \C-h"' + fi + + # CTRL-R - Paste the selected command from history into the command line + bind '"\C-r": " \C-e\C-u$(__fzf_history__)\e\C-e\e^\er"' + + # ALT-C - cd into the selected directory + bind '"\ec": " \C-e\C-u$(__fzf_cd__)\e\C-e\er\C-m"' +else + bind '"\C-x\C-e": shell-expand-line' + bind '"\C-x\C-r": redraw-current-line' + bind '"\C-x^": history-expand-line' + + # CTRL-T - Paste the selected file path into the command line + # - FIXME: Selected items are attached to the end regardless of cursor position + if [ $__use_tmux -eq 1 ]; then + bind '"\C-t": "\e$a \eddi$(__fzf_select_tmux__)\C-x\C-e\e0P$xa"' + else + bind '"\C-t": "\e$a \eddi$(__fzf_select__)\C-x\C-e\e0Px$a \C-x\C-r\exa "' + fi + bind -m vi-command '"\C-t": "i\C-t"' + + # CTRL-R - Paste the selected command from history into the command line + bind '"\C-r": "\eddi$(__fzf_history__)\C-x\C-e\C-x^\e$a\C-x\C-r"' + bind -m vi-command '"\C-r": "i\C-r"' + + # ALT-C - cd into the selected directory + bind '"\ec": "\eddi$(__fzf_cd__)\C-x\C-e\C-x\C-r\C-m"' + bind -m vi-command '"\ec": "i\ec"' +fi + +unset __use_tmux + +fi diff --git a/shell/key-bindings.fish b/shell/key-bindings.fish new file mode 100644 index 0000000..a08df3d --- /dev/null +++ b/shell/key-bindings.fish @@ -0,0 +1,69 @@ +# Key bindings +# ------------ +function fzf_key_bindings + # Due to a bug of fish, we cannot use command substitution, + # so we use temporary file instead + if [ -z "$TMPDIR" ] + set -g TMPDIR /tmp + end + + function __fzf_escape + while read item + echo -n (echo -n "$item" | sed -E 's/([ "$~'\''([{<>})])/\\\\\\1/g')' ' + end + end + + function __fzf_ctrl_t + set -q FZF_CTRL_T_COMMAND; or set -l FZF_CTRL_T_COMMAND " + command find -L . \\( -path '*/\\.*' -o -fstype 'dev' -o -fstype 'proc' \\) -prune \ + -o -type f -print \ + -o -type d -print \ + -o -type l -print 2> /dev/null | sed 1d | cut -b3-" + eval "$FZF_CTRL_T_COMMAND | "(__fzfcmd)" -m > $TMPDIR/fzf.result" + and commandline -i (cat $TMPDIR/fzf.result | __fzf_escape) + commandline -f repaint + rm -f $TMPDIR/fzf.result + end + + function __fzf_ctrl_r + history | eval (__fzfcmd) +s +m --tiebreak=index --toggle-sort=ctrl-r > $TMPDIR/fzf.result + and commandline (cat $TMPDIR/fzf.result) + commandline -f repaint + rm -f $TMPDIR/fzf.result + end + + function __fzf_alt_c + # Fish hangs if the command before pipe redirects (2> /dev/null) + command find -L . \( -path '*/\.*' -o -fstype 'dev' -o -fstype 'proc' \) \ + -prune -o -type d -print 2> /dev/null | sed 1d | cut -b3- | eval (__fzfcmd) +m > $TMPDIR/fzf.result + [ (cat $TMPDIR/fzf.result | wc -l) -gt 0 ] + and cd (cat $TMPDIR/fzf.result) + commandline -f repaint + rm -f $TMPDIR/fzf.result + end + + function __fzfcmd + set -q FZF_TMUX; or set FZF_TMUX 1 + + if [ $FZF_TMUX -eq 1 ] + if set -q FZF_TMUX_HEIGHT + echo "fzf-tmux -d$FZF_TMUX_HEIGHT" + else + echo "fzf-tmux -d40%" + end + else + echo "fzf" + end + end + + bind \ct '__fzf_ctrl_t' + bind \cr '__fzf_ctrl_r' + bind \ec '__fzf_alt_c' + + if bind -M insert > /dev/null 2>&1 + bind -M insert \ct '__fzf_ctrl_t' + bind -M insert \cr '__fzf_ctrl_r' + bind -M insert \ec '__fzf_alt_c' + end +end + diff --git a/shell/key-bindings.zsh b/shell/key-bindings.zsh new file mode 100644 index 0000000..735aa50 --- /dev/null +++ b/shell/key-bindings.zsh @@ -0,0 +1,52 @@ +# Key bindings +# ------------ +if [[ $- =~ i ]]; then + +# CTRL-T - Paste the selected file path(s) into the command line +__fsel() { + local cmd="${FZF_CTRL_T_COMMAND:-"command \\find -L . \\( -path '*/\\.*' -o -fstype 'dev' -o -fstype 'proc' \\) -prune \ + -o -type f -print \ + -o -type d -print \ + -o -type l -print 2> /dev/null | sed 1d | cut -b3-"}" + eval "$cmd" | $(__fzfcmd) -m | while read item; do + printf '%q ' "$item" + done + echo +} + +__fzfcmd() { + [ ${FZF_TMUX:-1} -eq 1 ] && echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || echo "fzf" +} + +fzf-file-widget() { + LBUFFER="${LBUFFER}$(__fsel)" + zle redisplay +} +zle -N fzf-file-widget +bindkey '^T' fzf-file-widget + +# ALT-C - cd into the selected directory +fzf-cd-widget() { + cd "${$(command \find -L . \( -path '*/\.*' -o -fstype 'dev' -o -fstype 'proc' \) -prune \ + -o -type d -print 2> /dev/null | sed 1d | cut -b3- | $(__fzfcmd) +m):-.}" + zle reset-prompt +} +zle -N fzf-cd-widget +bindkey '\ec' fzf-cd-widget + +# CTRL-R - Paste the selected command from history into the command line +fzf-history-widget() { + local selected restore_no_bang_hist + if selected=( $(fc -l 1 | $(__fzfcmd) +s --tac +m -n2..,.. --tiebreak=index --toggle-sort=ctrl-r -q "$LBUFFER") ); then + num=$selected[1] + if [ -n "$num" ]; then + zle vi-fetch-history -n $num + fi + fi + zle redisplay +} +zle -N fzf-history-widget +bindkey '^R' fzf-history-widget + +fi + diff --git a/src/Dockerfile.arch b/src/Dockerfile.arch new file mode 100644 index 0000000..d014651 --- /dev/null +++ b/src/Dockerfile.arch @@ -0,0 +1,28 @@ +FROM base/archlinux:2014.07.03 +MAINTAINER Junegunn Choi + +# apt-get +RUN pacman-key --populate archlinux && pacman-key --refresh-keys +RUN pacman-db-upgrade && pacman -Syu --noconfirm base-devel git + +# Install Go 1.4 +RUN cd / && curl \ + https://storage.googleapis.com/golang/go1.4.2.linux-amd64.tar.gz | \ + tar -xz && mv go go1.4 + +ENV GOPATH /go +ENV GOROOT /go1.4 +ENV PATH /go1.4/bin:$PATH + +# For i386 build +RUN echo '[multilib]' >> /etc/pacman.conf && \ + echo 'Include = /etc/pacman.d/mirrorlist' >> /etc/pacman.conf && \ + pacman-db-upgrade && yes | pacman -Sy gcc-multilib lib32-ncurses && \ + cd $GOROOT/src && GOARCH=386 ./make.bash + +# Volume +VOLUME /go + +# Default CMD +CMD cd /go/src/github.com/junegunn/fzf/src && /bin/bash + diff --git a/src/Dockerfile.centos b/src/Dockerfile.centos new file mode 100644 index 0000000..c03f43a --- /dev/null +++ b/src/Dockerfile.centos @@ -0,0 +1,21 @@ +FROM centos:centos7 +MAINTAINER Junegunn Choi + +# yum +RUN yum install -y git gcc make tar ncurses-devel + +# Install Go 1.4 +RUN cd / && curl \ + https://storage.googleapis.com/golang/go1.4.2.linux-amd64.tar.gz | \ + tar -xz && mv go go1.4 + +ENV GOPATH /go +ENV GOROOT /go1.4 +ENV PATH /go1.4/bin:$PATH + +# Volume +VOLUME /go + +# Default CMD +CMD cd /go/src/github.com/junegunn/fzf/src && /bin/bash + diff --git a/src/Dockerfile.ubuntu b/src/Dockerfile.ubuntu new file mode 100644 index 0000000..d9de120 --- /dev/null +++ b/src/Dockerfile.ubuntu @@ -0,0 +1,26 @@ +FROM ubuntu:14.04 +MAINTAINER Junegunn Choi + +# apt-get +RUN apt-get update && apt-get -y upgrade && \ + apt-get install -y --force-yes git curl build-essential libncurses-dev libgpm-dev + +# Install Go 1.4 +RUN cd / && curl \ + https://storage.googleapis.com/golang/go1.4.2.linux-amd64.tar.gz | \ + tar -xz && mv go go1.4 + +ENV GOPATH /go +ENV GOROOT /go1.4 +ENV PATH /go1.4/bin:$PATH + +# For i386 build +RUN apt-get install -y lib32ncurses5-dev && \ + cd $GOROOT/src && GOARCH=386 ./make.bash + +# Volume +VOLUME /go + +# Default CMD +CMD cd /go/src/github.com/junegunn/fzf/src && /bin/bash + diff --git a/src/LICENSE b/src/LICENSE new file mode 100644 index 0000000..fe4c31a --- /dev/null +++ b/src/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Junegunn Choi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..3304f57 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,97 @@ +ifndef GOPATH +$(error GOPATH is undefined) +endif + +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + GOOS := darwin + LDFLAGS := +ifdef STATIC +$(error Static linking not possible on OS X) +endif +else ifeq ($(UNAME_S),Linux) + GOOS := linux +ifdef STATIC + SUFFIX := -static + LDFLAGS := --ldflags '-extldflags "-static -ltinfo -lgpm"' +else + SUFFIX := + LDFLAGS := +endif +endif + +ifneq ($(shell uname -m),x86_64) +$(error "Build on $(UNAME_M) is not supported, yet.") +endif + +SOURCES := $(wildcard *.go */*.go) +BINDIR := ../bin + +BINARY32 := fzf-$(GOOS)_386$(SUFFIX) +BINARY64 := fzf-$(GOOS)_amd64$(SUFFIX) +VERSION = $(shell fzf/$(BINARY64) --version) +RELEASE32 = fzf-$(VERSION)-$(GOOS)_386$(SUFFIX) +RELEASE64 = fzf-$(VERSION)-$(GOOS)_amd64$(SUFFIX) + +all: release + +release: build + -cd fzf && cp $(BINARY32) $(RELEASE32) && tar -czf $(RELEASE32).tgz $(RELEASE32) + cd fzf && cp $(BINARY64) $(RELEASE64) && tar -czf $(RELEASE64).tgz $(RELEASE64) && \ + rm -f $(RELEASE32) $(RELEASE64) + +ifndef STATIC +build: test fzf/$(BINARY32) fzf/$(BINARY64) +else +build: test fzf/$(BINARY64) +endif + +test: + go get + go test -v ./... + +install: $(BINDIR)/fzf + +uninstall: + rm -f $(BINDIR)/fzf $(BINDIR)/$(BINARY64) + +clean: + cd fzf && rm -f fzf-* + +fzf/$(BINARY32): $(SOURCES) + cd fzf && GOARCH=386 CGO_ENABLED=1 go build -o $(BINARY32) + +fzf/$(BINARY64): $(SOURCES) + cd fzf && go build $(LDFLAGS) -o $(BINARY64) + +$(BINDIR)/fzf: fzf/$(BINARY64) | $(BINDIR) + cp -f fzf/$(BINARY64) $(BINDIR) + cd $(BINDIR) && ln -sf $(BINARY64) fzf + +$(BINDIR): + mkdir -p $@ + +docker-arch: + docker build -t junegunn/arch-sandbox - < Dockerfile.arch + +docker-ubuntu: + docker build -t junegunn/ubuntu-sandbox - < Dockerfile.ubuntu + +arch: docker-arch + docker run -i -t -v $(GOPATH):/go junegunn/$@-sandbox \ + sh -c 'cd /go/src/github.com/junegunn/fzf/src; /bin/bash' + +ubuntu: docker-ubuntu + docker run -i -t -v $(GOPATH):/go junegunn/$@-sandbox \ + sh -c 'cd /go/src/github.com/junegunn/fzf/src; /bin/bash' + +linux: docker-arch + docker run -i -t -v $(GOPATH):/go junegunn/arch-sandbox \ + /bin/bash -ci 'cd /go/src/github.com/junegunn/fzf/src; make' + +linux-static: docker-ubuntu + docker run -i -t -v $(GOPATH):/go junegunn/ubuntu-sandbox \ + /bin/bash -ci 'cd /go/src/github.com/junegunn/fzf/src; make STATIC=1' + +.PHONY: all build release test install uninstall clean docker \ + linux linux-static arch ubuntu docker-arch docker-ubuntu diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..18ccec3 --- /dev/null +++ b/src/README.md @@ -0,0 +1,121 @@ +fzf in Go +========= + +fzf in go + +This directory contains the source code for the new fzf implementation in +[Go][go]. + +Upgrade from Ruby version +------------------------- + +The install script has been updated to download the right binary for your +system. If you already have installed fzf, simply git-pull the repository and +rerun the install script. + +```sh +cd ~/.fzf +git pull +./install +``` + +Otherwise, follow [the instruction][install] as before. You can also install +fzf using Homebrew if you prefer that way. + +Motivations +----------- + +### No Ruby dependency + +There have always been complaints about fzf being a Ruby script. To make +matters worse, Ruby 2.1 removed ncurses binding from its standard libary. +Because of the change, users running Ruby 2.1 or above are forced to build C +extensions of curses gem to meet the requirement of fzf. The new Go version +will be distributed as an executable binary so it will be much more accessible +and should be easier to setup. + +### Performance + +Many people have been surprised to see how fast fzf is even when it was +written in Ruby. It stays quite responsive even for 100k+ lines, which is +well above the size of the usual input. + +The new Go version, of course, is significantly faster than that. It has all +the performance optimization techniques used in Ruby implementation and more. +It also doesn't suffer from [GIL][gil], so the search performance scales +proportional to the number of CPU cores. On my MacBook Pro (Mid 2012), the new +version was shown to be an order of magnitude faster on certain cases. It also +starts much faster though the difference may not be noticeable. + +Differences with Ruby version +----------------------------- + +The Go version is designed to be perfectly compatible with the previous Ruby +version. The only behavioral difference is that the new version ignores the +numeric argument to `--sort=N` option and always sorts the result regardless +of the number of matches. The value was introduced to limit the response time +of the query, but the Go version is blazingly fast (almost instant response +even for 1M+ items) so I decided that it's no longer required. + +System requirements +------------------- + +Currently, prebuilt binaries are provided only for OS X and Linux. The install +script will fall back to the legacy Ruby version on the other systems, but if +you have Go 1.4 installed, you can try building it yourself. + +However, as pointed out in [golang.org/doc/install][req], the Go version may +not run on CentOS/RHEL 5.x, and if that's the case, the install script will +choose the Ruby version instead. + +The Go version depends on [ncurses][ncurses] and some Unix system calls, so it +shouldn't run natively on Windows at the moment. But it won't be impossible to +support Windows by falling back to a cross-platform alternative such as +[termbox][termbox] only on Windows. If you're interested in making fzf work on +Windows, please let me know. + +Build +----- + +```sh +# Build fzf executables and tarballs +make + +# Install the executable to ../bin directory +make install + +# Build executables and tarballs for Linux using Docker +make linux +``` + +Contribution +------------ + +For the time being, I will not add or accept any new features until we can be +sure that the implementation is stable and we have a sufficient number of test +cases. However, fixes for obvious bugs and new test cases are welcome. + +I also care much about the performance of the implementation, so please make +sure that your change does not result in performance regression. And please be +noted that we don't have a quantitative measure of the performance yet. + +Third-party libraries used +-------------------------- + +- [ncurses][ncurses] +- [mattn/go-runewidth](https://github.com/mattn/go-runewidth) + - Licensed under [MIT](http://mattn.mit-license.org/2013) +- [mattn/go-shellwords](https://github.com/mattn/go-shellwords) + - Licensed under [MIT](http://mattn.mit-license.org/2014) + +License +------- + +[MIT](LICENSE) + +[install]: https://github.com/junegunn/fzf#installation +[go]: https://golang.org/ +[gil]: http://en.wikipedia.org/wiki/Global_Interpreter_Lock +[ncurses]: https://www.gnu.org/software/ncurses/ +[req]: http://golang.org/doc/install +[termbox]: https://github.com/nsf/termbox-go diff --git a/src/algo/algo.go b/src/algo/algo.go new file mode 100644 index 0000000..ac7bd8b --- /dev/null +++ b/src/algo/algo.go @@ -0,0 +1,199 @@ +package algo + +import ( + "strings" + "unicode" + + "github.com/junegunn/fzf/src/util" +) + +/* + * String matching algorithms here do not use strings.ToLower to avoid + * performance penalty. And they assume pattern runes are given in lowercase + * letters when caseSensitive is false. + * + * In short: They try to do as little work as possible. + */ + +func runeAt(runes []rune, index int, max int, forward bool) rune { + if forward { + return runes[index] + } + return runes[max-index-1] +} + +// FuzzyMatch performs fuzzy-match +func FuzzyMatch(caseSensitive bool, forward bool, runes []rune, pattern []rune) (int, int) { + if len(pattern) == 0 { + return 0, 0 + } + + // 0. (FIXME) How to find the shortest match? + // a_____b__c__abc + // ^^^^^^^^^^ ^^^ + // 1. forward scan (abc) + // *-----*-----*> + // a_____b___abc__ + // 2. reverse scan (cba) + // a_____b___abc__ + // <*** + pidx := 0 + sidx := -1 + eidx := -1 + + lenRunes := len(runes) + lenPattern := len(pattern) + + for index := range runes { + char := runeAt(runes, index, lenRunes, forward) + // This is considerably faster than blindly applying strings.ToLower to the + // whole string + if !caseSensitive { + // Partially inlining `unicode.ToLower`. Ugly, but makes a noticeable + // difference in CPU cost. (Measured on Go 1.4.1. Also note that the Go + // compiler as of now does not inline non-leaf functions.) + if char >= 'A' && char <= 'Z' { + char += 32 + } else if char > unicode.MaxASCII { + char = unicode.To(unicode.LowerCase, char) + } + } + pchar := runeAt(pattern, pidx, lenPattern, forward) + if char == pchar { + if sidx < 0 { + sidx = index + } + if pidx++; pidx == lenPattern { + eidx = index + 1 + break + } + } + } + + if sidx >= 0 && eidx >= 0 { + pidx-- + for index := eidx - 1; index >= sidx; index-- { + char := runeAt(runes, index, lenRunes, forward) + if !caseSensitive { + if char >= 'A' && char <= 'Z' { + char += 32 + } else if char > unicode.MaxASCII { + char = unicode.To(unicode.LowerCase, char) + } + } + + pchar := runeAt(pattern, pidx, lenPattern, forward) + if char == pchar { + if pidx--; pidx < 0 { + sidx = index + break + } + } + } + if forward { + return sidx, eidx + } + return lenRunes - eidx, lenRunes - sidx + } + return -1, -1 +} + +// ExactMatchNaive is a basic string searching algorithm that handles case +// sensitivity. Although naive, it still performs better than the combination +// of strings.ToLower + strings.Index for typical fzf use cases where input +// strings and patterns are not very long. +// +// We might try to implement better algorithms in the future: +// http://en.wikipedia.org/wiki/String_searching_algorithm +func ExactMatchNaive(caseSensitive bool, forward bool, runes []rune, pattern []rune) (int, int) { + if len(pattern) == 0 { + return 0, 0 + } + + lenRunes := len(runes) + lenPattern := len(pattern) + + if lenRunes < lenPattern { + return -1, -1 + } + + pidx := 0 + for index := 0; index < lenRunes; index++ { + char := runeAt(runes, index, lenRunes, forward) + if !caseSensitive { + if char >= 'A' && char <= 'Z' { + char += 32 + } else if char > unicode.MaxASCII { + char = unicode.To(unicode.LowerCase, char) + } + } + pchar := runeAt(pattern, pidx, lenPattern, forward) + if pchar == char { + pidx++ + if pidx == lenPattern { + if forward { + return index - lenPattern + 1, index + 1 + } + return lenRunes - (index + 1), lenRunes - (index - lenPattern + 1) + } + } else { + index -= pidx + pidx = 0 + } + } + return -1, -1 +} + +// PrefixMatch performs prefix-match +func PrefixMatch(caseSensitive bool, forward bool, runes []rune, pattern []rune) (int, int) { + if len(runes) < len(pattern) { + return -1, -1 + } + + for index, r := range pattern { + char := runes[index] + if !caseSensitive { + char = unicode.ToLower(char) + } + if char != r { + return -1, -1 + } + } + return 0, len(pattern) +} + +// SuffixMatch performs suffix-match +func SuffixMatch(caseSensitive bool, forward bool, input []rune, pattern []rune) (int, int) { + runes := util.TrimRight(input) + trimmedLen := len(runes) + diff := trimmedLen - len(pattern) + if diff < 0 { + return -1, -1 + } + + for index, r := range pattern { + char := runes[index+diff] + if !caseSensitive { + char = unicode.ToLower(char) + } + if char != r { + return -1, -1 + } + } + return trimmedLen - len(pattern), trimmedLen +} + +// EqualMatch performs equal-match +func EqualMatch(caseSensitive bool, forward bool, runes []rune, pattern []rune) (int, int) { + if len(runes) != len(pattern) { + return -1, -1 + } + runesStr := string(runes) + if !caseSensitive { + runesStr = strings.ToLower(runesStr) + } + if runesStr == string(pattern) { + return 0, len(pattern) + } + return -1, -1 +} diff --git a/src/algo/algo_test.go b/src/algo/algo_test.go new file mode 100644 index 0000000..95a020b --- /dev/null +++ b/src/algo/algo_test.go @@ -0,0 +1,69 @@ +package algo + +import ( + "strings" + "testing" +) + +func assertMatch(t *testing.T, fun func(bool, bool, []rune, []rune) (int, int), caseSensitive bool, forward bool, input string, pattern string, sidx int, eidx int) { + if !caseSensitive { + pattern = strings.ToLower(pattern) + } + s, e := fun(caseSensitive, forward, []rune(input), []rune(pattern)) + if s != sidx { + t.Errorf("Invalid start index: %d (expected: %d, %s / %s)", s, sidx, input, pattern) + } + if e != eidx { + t.Errorf("Invalid end index: %d (expected: %d, %s / %s)", e, eidx, input, pattern) + } +} + +func TestFuzzyMatch(t *testing.T) { + assertMatch(t, FuzzyMatch, false, true, "fooBarbaz", "oBZ", 2, 9) + assertMatch(t, FuzzyMatch, true, true, "fooBarbaz", "oBZ", -1, -1) + assertMatch(t, FuzzyMatch, true, true, "fooBarbaz", "oBz", 2, 9) + assertMatch(t, FuzzyMatch, true, true, "fooBarbaz", "fooBarbazz", -1, -1) +} + +func TestFuzzyMatchBackward(t *testing.T) { + assertMatch(t, FuzzyMatch, false, true, "foobar fb", "fb", 0, 4) + assertMatch(t, FuzzyMatch, false, false, "foobar fb", "fb", 7, 9) +} + +func TestExactMatchNaive(t *testing.T) { + for _, dir := range []bool{true, false} { + assertMatch(t, ExactMatchNaive, false, dir, "fooBarbaz", "oBA", 2, 5) + assertMatch(t, ExactMatchNaive, true, dir, "fooBarbaz", "oBA", -1, -1) + assertMatch(t, ExactMatchNaive, true, dir, "fooBarbaz", "fooBarbazz", -1, -1) + } +} + +func TestExactMatchNaiveBackward(t *testing.T) { + assertMatch(t, FuzzyMatch, false, true, "foobar foob", "oo", 1, 3) + assertMatch(t, FuzzyMatch, false, false, "foobar foob", "oo", 8, 10) +} + +func TestPrefixMatch(t *testing.T) { + for _, dir := range []bool{true, false} { + assertMatch(t, PrefixMatch, false, dir, "fooBarbaz", "Foo", 0, 3) + assertMatch(t, PrefixMatch, true, dir, "fooBarbaz", "Foo", -1, -1) + assertMatch(t, PrefixMatch, false, dir, "fooBarbaz", "baz", -1, -1) + } +} + +func TestSuffixMatch(t *testing.T) { + for _, dir := range []bool{true, false} { + assertMatch(t, SuffixMatch, false, dir, "fooBarbaz", "Foo", -1, -1) + assertMatch(t, SuffixMatch, false, dir, "fooBarbaz", "baz", 6, 9) + assertMatch(t, SuffixMatch, true, dir, "fooBarbaz", "Baz", -1, -1) + } +} + +func TestEmptyPattern(t *testing.T) { + for _, dir := range []bool{true, false} { + assertMatch(t, FuzzyMatch, true, dir, "foobar", "", 0, 0) + assertMatch(t, ExactMatchNaive, true, dir, "foobar", "", 0, 0) + assertMatch(t, PrefixMatch, true, dir, "foobar", "", 0, 0) + assertMatch(t, SuffixMatch, true, dir, "foobar", "", 6, 6) + } +} diff --git a/src/ansi.go b/src/ansi.go new file mode 100644 index 0000000..876229f --- /dev/null +++ b/src/ansi.go @@ -0,0 +1,149 @@ +package fzf + +import ( + "bytes" + "regexp" + "strconv" + "strings" + "unicode/utf8" +) + +type ansiOffset struct { + offset [2]int32 + color ansiState +} + +type ansiState struct { + fg int + bg int + bold bool +} + +func (s *ansiState) colored() bool { + return s.fg != -1 || s.bg != -1 || s.bold +} + +func (s *ansiState) equals(t *ansiState) bool { + if t == nil { + return !s.colored() + } + return s.fg == t.fg && s.bg == t.bg && s.bold == t.bold +} + +var ansiRegex *regexp.Regexp + +func init() { + ansiRegex = regexp.MustCompile("\x1b\\[[0-9;]*[mK]") +} + +func extractColor(str string, state *ansiState) (string, []ansiOffset, *ansiState) { + var offsets []ansiOffset + var output bytes.Buffer + + if state != nil { + offsets = append(offsets, ansiOffset{[2]int32{0, 0}, *state}) + } + + idx := 0 + for _, offset := range ansiRegex.FindAllStringIndex(str, -1) { + output.WriteString(str[idx:offset[0]]) + newState := interpretCode(str[offset[0]:offset[1]], state) + + if !newState.equals(state) { + if state != nil { + // Update last offset + (&offsets[len(offsets)-1]).offset[1] = int32(utf8.RuneCount(output.Bytes())) + } + + if newState.colored() { + // Append new offset + state = newState + newLen := int32(utf8.RuneCount(output.Bytes())) + offsets = append(offsets, ansiOffset{[2]int32{newLen, newLen}, *state}) + } else { + // Discard state + state = nil + } + } + + idx = offset[1] + } + + rest := str[idx:] + if len(rest) > 0 { + output.WriteString(rest) + if state != nil { + // Update last offset + (&offsets[len(offsets)-1]).offset[1] = int32(utf8.RuneCount(output.Bytes())) + } + } + return output.String(), offsets, state +} + +func interpretCode(ansiCode string, prevState *ansiState) *ansiState { + // State + var state *ansiState + if prevState == nil { + state = &ansiState{-1, -1, false} + } else { + state = &ansiState{prevState.fg, prevState.bg, prevState.bold} + } + if ansiCode[len(ansiCode)-1] == 'K' { + return state + } + + ptr := &state.fg + state256 := 0 + + init := func() { + state.fg = -1 + state.bg = -1 + state.bold = false + state256 = 0 + } + + ansiCode = ansiCode[2 : len(ansiCode)-1] + if len(ansiCode) == 0 { + init() + } + for _, code := range strings.Split(ansiCode, ";") { + if num, err := strconv.Atoi(code); err == nil { + switch state256 { + case 0: + switch num { + case 38: + ptr = &state.fg + state256++ + case 48: + ptr = &state.bg + state256++ + case 39: + state.fg = -1 + case 49: + state.bg = -1 + case 1: + state.bold = true + case 0: + init() + default: + if num >= 30 && num <= 37 { + state.fg = num - 30 + } else if num >= 40 && num <= 47 { + state.bg = num - 40 + } + } + case 1: + switch num { + case 5: + state256++ + default: + state256 = 0 + } + case 2: + *ptr = num + state256 = 0 + } + } + } + return state +} diff --git a/src/ansi_test.go b/src/ansi_test.go new file mode 100644 index 0000000..e278fe9 --- /dev/null +++ b/src/ansi_test.go @@ -0,0 +1,152 @@ +package fzf + +import ( + "fmt" + "testing" +) + +func TestExtractColor(t *testing.T) { + assert := func(offset ansiOffset, b int32, e int32, fg int, bg int, bold bool) { + if offset.offset[0] != b || offset.offset[1] != e || + offset.color.fg != fg || offset.color.bg != bg || offset.color.bold != bold { + t.Error(offset, b, e, fg, bg, bold) + } + } + + src := "hello world" + var state *ansiState + clean := "\x1b[0m" + check := func(assertion func(ansiOffsets []ansiOffset, state *ansiState)) { + output, ansiOffsets, newState := extractColor(src, state) + state = newState + if output != "hello world" { + t.Errorf("Invalid output: {}", output) + } + fmt.Println(src, ansiOffsets, clean) + assertion(ansiOffsets, state) + } + + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) > 0 { + t.Fail() + } + }) + + state = nil + src = "\x1b[0mhello world" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) > 0 { + t.Fail() + } + }) + + state = nil + src = "\x1b[1mhello world" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + assert(offsets[0], 0, 11, -1, -1, true) + }) + + state = nil + src = "\x1b[1mhello \x1b[mworld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + assert(offsets[0], 0, 6, -1, -1, true) + }) + + state = nil + src = "\x1b[1mhello \x1b[Kworld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + assert(offsets[0], 0, 11, -1, -1, true) + }) + + state = nil + src = "hello \x1b[34;45;1mworld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + assert(offsets[0], 6, 11, 4, 5, true) + }) + + state = nil + src = "hello \x1b[34;45;1mwor\x1b[34;45;1mld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + assert(offsets[0], 6, 11, 4, 5, true) + }) + + state = nil + src = "hello \x1b[34;45;1mwor\x1b[0mld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + assert(offsets[0], 6, 9, 4, 5, true) + }) + + state = nil + src = "hello \x1b[34;48;5;233;1mwo\x1b[38;5;161mr\x1b[0ml\x1b[38;5;161md" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 3 { + t.Fail() + } + assert(offsets[0], 6, 8, 4, 233, true) + assert(offsets[1], 8, 9, 161, 233, true) + assert(offsets[2], 10, 11, 161, -1, false) + }) + + // {38,48};5;{38,48} + state = nil + src = "hello \x1b[38;5;38;48;5;48;1mwor\x1b[38;5;48;48;5;38ml\x1b[0md" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 2 { + t.Fail() + } + assert(offsets[0], 6, 9, 38, 48, true) + assert(offsets[1], 9, 10, 48, 38, true) + }) + + src = "hello \x1b[32;1mworld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + if state.fg != 2 || state.bg != -1 || !state.bold { + t.Fail() + } + assert(offsets[0], 6, 11, 2, -1, true) + }) + + src = "hello world" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 1 { + t.Fail() + } + if state.fg != 2 || state.bg != -1 || !state.bold { + t.Fail() + } + assert(offsets[0], 0, 11, 2, -1, true) + }) + + src = "hello \x1b[0;38;5;200;48;5;100mworld" + check(func(offsets []ansiOffset, state *ansiState) { + if len(offsets) != 2 { + t.Fail() + } + if state.fg != 200 || state.bg != 100 || state.bold { + t.Fail() + } + assert(offsets[0], 0, 6, 2, -1, true) + assert(offsets[1], 6, 11, 200, 100, false) + }) +} diff --git a/src/cache.go b/src/cache.go new file mode 100644 index 0000000..d2ec00b --- /dev/null +++ b/src/cache.go @@ -0,0 +1,53 @@ +package fzf + +import "sync" + +// queryCache associates strings to lists of items +type queryCache map[string][]*Item + +// ChunkCache associates Chunk and query string to lists of items +type ChunkCache struct { + mutex sync.Mutex + cache map[*Chunk]*queryCache +} + +// NewChunkCache returns a new ChunkCache +func NewChunkCache() ChunkCache { + return ChunkCache{sync.Mutex{}, make(map[*Chunk]*queryCache)} +} + +// Add adds the list to the cache +func (cc *ChunkCache) Add(chunk *Chunk, key string, list []*Item) { + if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax { + return + } + + cc.mutex.Lock() + defer cc.mutex.Unlock() + + qc, ok := cc.cache[chunk] + if !ok { + cc.cache[chunk] = &queryCache{} + qc = cc.cache[chunk] + } + (*qc)[key] = list +} + +// Find is called to lookup ChunkCache +func (cc *ChunkCache) Find(chunk *Chunk, key string) ([]*Item, bool) { + if len(key) == 0 || !chunk.IsFull() { + return nil, false + } + + cc.mutex.Lock() + defer cc.mutex.Unlock() + + qc, ok := cc.cache[chunk] + if ok { + list, ok := (*qc)[key] + if ok { + return list, true + } + } + return nil, false +} diff --git a/src/cache_test.go b/src/cache_test.go new file mode 100644 index 0000000..05f904c --- /dev/null +++ b/src/cache_test.go @@ -0,0 +1,40 @@ +package fzf + +import "testing" + +func TestChunkCache(t *testing.T) { + cache := NewChunkCache() + chunk2 := make(Chunk, chunkSize) + chunk1p := &Chunk{} + chunk2p := &chunk2 + items1 := []*Item{&Item{}} + items2 := []*Item{&Item{}, &Item{}} + cache.Add(chunk1p, "foo", items1) + cache.Add(chunk2p, "foo", items1) + cache.Add(chunk2p, "bar", items2) + + { // chunk1 is not full + cached, found := cache.Find(chunk1p, "foo") + if found { + t.Error("Cached disabled for non-empty chunks", found, cached) + } + } + { + cached, found := cache.Find(chunk2p, "foo") + if !found || len(cached) != 1 { + t.Error("Expected 1 item cached", found, cached) + } + } + { + cached, found := cache.Find(chunk2p, "bar") + if !found || len(cached) != 2 { + t.Error("Expected 2 items cached", found, cached) + } + } + { + cached, found := cache.Find(chunk1p, "foobar") + if found { + t.Error("Expected 0 item cached", found, cached) + } + } +} diff --git a/src/chunklist.go b/src/chunklist.go new file mode 100644 index 0000000..a953fae --- /dev/null +++ b/src/chunklist.go @@ -0,0 +1,93 @@ +package fzf + +import "sync" + +// Chunk is a list of Item pointers whose size has the upper limit of chunkSize +type Chunk []*Item // >>> []Item + +// ItemBuilder is a closure type that builds Item object from a pointer to a +// string and an integer +type ItemBuilder func([]byte, int) *Item + +// ChunkList is a list of Chunks +type ChunkList struct { + chunks []*Chunk + count int + mutex sync.Mutex + trans ItemBuilder +} + +// NewChunkList returns a new ChunkList +func NewChunkList(trans ItemBuilder) *ChunkList { + return &ChunkList{ + chunks: []*Chunk{}, + count: 0, + mutex: sync.Mutex{}, + trans: trans} +} + +func (c *Chunk) push(trans ItemBuilder, data []byte, index int) bool { + item := trans(data, index) + if item != nil { + *c = append(*c, item) + return true + } + return false +} + +// IsFull returns true if the Chunk is full +func (c *Chunk) IsFull() bool { + return len(*c) == chunkSize +} + +func (cl *ChunkList) lastChunk() *Chunk { + return cl.chunks[len(cl.chunks)-1] +} + +// CountItems returns the total number of Items +func CountItems(cs []*Chunk) int { + if len(cs) == 0 { + return 0 + } + return chunkSize*(len(cs)-1) + len(*(cs[len(cs)-1])) +} + +// Push adds the item to the list +func (cl *ChunkList) Push(data []byte) bool { + cl.mutex.Lock() + defer cl.mutex.Unlock() + + if len(cl.chunks) == 0 || cl.lastChunk().IsFull() { + newChunk := Chunk(make([]*Item, 0, chunkSize)) + cl.chunks = append(cl.chunks, &newChunk) + } + + if cl.lastChunk().push(cl.trans, data, cl.count) { + cl.count++ + return true + } + return false +} + +// Snapshot returns immutable snapshot of the ChunkList +func (cl *ChunkList) Snapshot() ([]*Chunk, int) { + cl.mutex.Lock() + defer cl.mutex.Unlock() + + ret := make([]*Chunk, len(cl.chunks)) + copy(ret, cl.chunks) + + // Duplicate the last chunk + if cnt := len(ret); cnt > 0 { + ret[cnt-1] = ret[cnt-1].dupe() + } + return ret, cl.count +} + +func (c *Chunk) dupe() *Chunk { + newChunk := make(Chunk, len(*c)) + for idx, ptr := range *c { + newChunk[idx] = ptr + } + return &newChunk +} diff --git a/src/chunklist_test.go b/src/chunklist_test.go new file mode 100644 index 0000000..26795ef --- /dev/null +++ b/src/chunklist_test.go @@ -0,0 +1,74 @@ +package fzf + +import ( + "fmt" + "testing" +) + +func TestChunkList(t *testing.T) { + cl := NewChunkList(func(s []byte, i int) *Item { + return &Item{text: []rune(string(s)), rank: Rank{0, 0, uint32(i * 2)}} + }) + + // Snapshot + snapshot, count := cl.Snapshot() + if len(snapshot) > 0 || count > 0 { + t.Error("Snapshot should be empty now") + } + + // Add some data + cl.Push([]byte("hello")) + cl.Push([]byte("world")) + + // Previously created snapshot should remain the same + if len(snapshot) > 0 { + t.Error("Snapshot should not have changed") + } + + // But the new snapshot should contain the added items + snapshot, count = cl.Snapshot() + if len(snapshot) != 1 && count != 2 { + t.Error("Snapshot should not be empty now") + } + + // Check the content of the ChunkList + chunk1 := snapshot[0] + if len(*chunk1) != 2 { + t.Error("Snapshot should contain only two items") + } + if string((*chunk1)[0].text) != "hello" || (*chunk1)[0].rank.index != 0 || + string((*chunk1)[1].text) != "world" || (*chunk1)[1].rank.index != 2 { + t.Error("Invalid data") + } + if chunk1.IsFull() { + t.Error("Chunk should not have been marked full yet") + } + + // Add more data + for i := 0; i < chunkSize*2; i++ { + cl.Push([]byte(fmt.Sprintf("item %d", i))) + } + + // Previous snapshot should remain the same + if len(snapshot) != 1 { + t.Error("Snapshot should stay the same") + } + + // New snapshot + snapshot, count = cl.Snapshot() + if len(snapshot) != 3 || !snapshot[0].IsFull() || + !snapshot[1].IsFull() || snapshot[2].IsFull() || count != chunkSize*2+2 { + t.Error("Expected two full chunks and one more chunk") + } + if len(*snapshot[2]) != 2 { + t.Error("Unexpected number of items") + } + + cl.Push([]byte("hello")) + cl.Push([]byte("world")) + + lastChunkCount := len(*snapshot[len(snapshot)-1]) + if lastChunkCount != 2 { + t.Error("Unexpected number of items:", lastChunkCount) + } +} diff --git a/src/constants.go b/src/constants.go new file mode 100644 index 0000000..1cc4f81 --- /dev/null +++ b/src/constants.go @@ -0,0 +1,56 @@ +package fzf + +import ( + "time" + + "github.com/junegunn/fzf/src/util" +) + +const ( + // Current version + version = "0.10.5" + + // Core + coordinatorDelayMax time.Duration = 100 * time.Millisecond + coordinatorDelayStep time.Duration = 10 * time.Millisecond + + // Reader + defaultCommand = `find . -path '*/\.*' -prune -o -type f -print -o -type l -print 2> /dev/null | sed s/^..//` + + // Terminal + initialDelay = 100 * time.Millisecond + spinnerDuration = 200 * time.Millisecond + + // Matcher + progressMinDuration = 200 * time.Millisecond + + // Capacity of each chunk + chunkSize int = 100 + + // Do not cache results of low selectivity queries + queryCacheMax int = chunkSize / 5 + + // Not to cache mergers with large lists + mergerCacheMax int = 100000 + + // History + defaultHistoryMax int = 1000 +) + +// fzf events +const ( + EvtReadNew util.EventType = iota + EvtReadFin + EvtSearchNew + EvtSearchProgress + EvtSearchFin + EvtHeader + EvtClose +) + +const ( + exitOk = 0 + exitNoMatch = 1 + exitError = 2 + exitInterrupt = 130 +) diff --git a/src/core.go b/src/core.go new file mode 100644 index 0000000..35d7ced --- /dev/null +++ b/src/core.go @@ -0,0 +1,283 @@ +/* +Package fzf implements fzf, a command-line fuzzy finder. + +The MIT License (MIT) + +Copyright (c) 2015 Junegunn Choi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package fzf + +import ( + "fmt" + "os" + "runtime" + "time" + + "github.com/junegunn/fzf/src/util" +) + +func initProcs() { + runtime.GOMAXPROCS(runtime.NumCPU()) +} + +/* +Reader -> EvtReadFin +Reader -> EvtReadNew -> Matcher (restart) +Terminal -> EvtSearchNew:bool -> Matcher (restart) +Matcher -> EvtSearchProgress -> Terminal (update info) +Matcher -> EvtSearchFin -> Terminal (update list) +Matcher -> EvtHeader -> Terminal (update header) +*/ + +// Run starts fzf +func Run(opts *Options) { + initProcs() + + sort := opts.Sort > 0 + rankTiebreak = opts.Tiebreak + + if opts.Version { + fmt.Println(version) + os.Exit(exitOk) + } + + // Event channel + eventBox := util.NewEventBox() + + // ANSI code processor + ansiProcessor := func(data []byte) ([]rune, []ansiOffset) { + return util.BytesToRunes(data), nil + } + ansiProcessorRunes := func(data []rune) ([]rune, []ansiOffset) { + return data, nil + } + if opts.Ansi { + if opts.Theme != nil { + var state *ansiState + ansiProcessor = func(data []byte) ([]rune, []ansiOffset) { + trimmed, offsets, newState := extractColor(string(data), state) + state = newState + return []rune(trimmed), offsets + } + } else { + // When color is disabled but ansi option is given, + // we simply strip out ANSI codes from the input + ansiProcessor = func(data []byte) ([]rune, []ansiOffset) { + trimmed, _, _ := extractColor(string(data), nil) + return []rune(trimmed), nil + } + } + ansiProcessorRunes = func(data []rune) ([]rune, []ansiOffset) { + return ansiProcessor([]byte(string(data))) + } + } + + // Chunk list + var chunkList *ChunkList + header := make([]string, 0, opts.HeaderLines) + if len(opts.WithNth) == 0 { + chunkList = NewChunkList(func(data []byte, index int) *Item { + if len(header) < opts.HeaderLines { + header = append(header, string(data)) + eventBox.Set(EvtHeader, header) + return nil + } + runes, colors := ansiProcessor(data) + return &Item{ + text: runes, + index: uint32(index), + colors: colors, + rank: Rank{0, 0, uint32(index)}} + }) + } else { + chunkList = NewChunkList(func(data []byte, index int) *Item { + runes := util.BytesToRunes(data) + tokens := Tokenize(runes, opts.Delimiter) + trans := Transform(tokens, opts.WithNth) + if len(header) < opts.HeaderLines { + header = append(header, string(joinTokens(trans))) + eventBox.Set(EvtHeader, header) + return nil + } + item := Item{ + text: joinTokens(trans), + origText: &runes, + index: uint32(index), + colors: nil, + rank: Rank{0, 0, uint32(index)}} + + trimmed, colors := ansiProcessorRunes(item.text) + item.text = trimmed + item.colors = colors + return &item + }) + } + + // Reader + streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync + if !streamingFilter { + reader := Reader{func(data []byte) bool { + return chunkList.Push(data) + }, eventBox, opts.ReadZero} + go reader.ReadSource() + } + + // Matcher + patternBuilder := func(runes []rune) *Pattern { + return BuildPattern( + opts.Mode, opts.Case, opts.Tiebreak != byEnd, + opts.Nth, opts.Delimiter, runes) + } + matcher := NewMatcher(patternBuilder, sort, opts.Tac, eventBox) + + // Filtering mode + if opts.Filter != nil { + if opts.PrintQuery { + fmt.Println(*opts.Filter) + } + + pattern := patternBuilder([]rune(*opts.Filter)) + + found := false + if streamingFilter { + reader := Reader{ + func(runes []byte) bool { + item := chunkList.trans(runes, 0) + if item != nil && pattern.MatchItem(item) { + fmt.Println(string(item.text)) + found = true + } + return false + }, eventBox, opts.ReadZero} + reader.ReadSource() + } else { + eventBox.Unwatch(EvtReadNew) + eventBox.WaitFor(EvtReadFin) + + snapshot, _ := chunkList.Snapshot() + merger, _ := matcher.scan(MatchRequest{ + chunks: snapshot, + pattern: pattern}) + for i := 0; i < merger.Length(); i++ { + fmt.Println(merger.Get(i).AsString(opts.Ansi)) + found = true + } + } + if found { + os.Exit(exitOk) + } + os.Exit(exitNoMatch) + } + + // Synchronous search + if opts.Sync { + eventBox.Unwatch(EvtReadNew) + eventBox.WaitFor(EvtReadFin) + } + + // Go interactive + go matcher.Loop() + + // Terminal I/O + terminal := NewTerminal(opts, eventBox) + deferred := opts.Select1 || opts.Exit0 + go terminal.Loop() + if !deferred { + terminal.startChan <- true + } + + // Event coordination + reading := true + ticks := 0 + eventBox.Watch(EvtReadNew) + for { + delay := true + ticks++ + eventBox.Wait(func(events *util.Events) { + defer events.Clear() + for evt, value := range *events { + switch evt { + + case EvtReadNew, EvtReadFin: + reading = reading && evt == EvtReadNew + snapshot, count := chunkList.Snapshot() + terminal.UpdateCount(count, !reading) + matcher.Reset(snapshot, terminal.Input(), false, !reading, sort) + + case EvtSearchNew: + switch val := value.(type) { + case bool: + sort = val + } + snapshot, _ := chunkList.Snapshot() + matcher.Reset(snapshot, terminal.Input(), true, !reading, sort) + delay = false + + case EvtSearchProgress: + switch val := value.(type) { + case float32: + terminal.UpdateProgress(val) + } + + case EvtHeader: + terminal.UpdateHeader(value.([]string)) + + case EvtSearchFin: + switch val := value.(type) { + case *Merger: + if deferred { + count := val.Length() + if opts.Select1 && count > 1 || opts.Exit0 && !opts.Select1 && count > 0 { + deferred = false + terminal.startChan <- true + } else if val.final { + if opts.Exit0 && count == 0 || opts.Select1 && count == 1 { + if opts.PrintQuery { + fmt.Println(opts.Query) + } + if len(opts.Expect) > 0 { + fmt.Println() + } + for i := 0; i < count; i++ { + fmt.Println(val.Get(i).AsString(opts.Ansi)) + } + if count > 0 { + os.Exit(exitOk) + } + os.Exit(exitNoMatch) + } + deferred = false + terminal.startChan <- true + } + } + terminal.UpdateList(val) + } + } + } + }) + if delay && reading { + dur := util.DurWithin( + time.Duration(ticks)*coordinatorDelayStep, + 0, coordinatorDelayMax) + time.Sleep(dur) + } + } +} diff --git a/src/curses/curses.go b/src/curses/curses.go new file mode 100644 index 0000000..59cea3b --- /dev/null +++ b/src/curses/curses.go @@ -0,0 +1,556 @@ +package curses + +/* +#include +#include +#cgo LDFLAGS: -lncurses +*/ +import "C" + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + "unicode/utf8" +) + +// Types of user action +const ( + Rune = iota + + CtrlA + CtrlB + CtrlC + CtrlD + CtrlE + CtrlF + CtrlG + CtrlH + Tab + CtrlJ + CtrlK + CtrlL + CtrlM + CtrlN + CtrlO + CtrlP + CtrlQ + CtrlR + CtrlS + CtrlT + CtrlU + CtrlV + CtrlW + CtrlX + CtrlY + CtrlZ + ESC + + Invalid + Mouse + + BTab + BSpace + + Del + PgUp + PgDn + + Up + Down + Left + Right + Home + End + + SLeft + SRight + + F1 + F2 + F3 + F4 + + AltBS + AltA + AltB + AltC + AltD + AltE + AltF + + AltZ = AltA + 'z' - 'a' +) + +// Pallete +const ( + ColNormal = iota + ColPrompt + ColMatch + ColCurrent + ColCurrentMatch + ColSpinner + ColInfo + ColCursor + ColSelected + ColHeader + ColUser +) + +const ( + doubleClickDuration = 500 * time.Millisecond +) + +type ColorTheme struct { + UseDefault bool + Fg int16 + Bg int16 + DarkBg int16 + Prompt int16 + Match int16 + Current int16 + CurrentMatch int16 + Spinner int16 + Info int16 + Cursor int16 + Selected int16 + Header int16 +} + +type Event struct { + Type int + Char rune + MouseEvent *MouseEvent +} + +type MouseEvent struct { + Y int + X int + S int + Down bool + Double bool + Mod bool +} + +var ( + _buf []byte + _in *os.File + _color func(int, bool) C.int + _colorMap map[int]int + _prevDownTime time.Time + _clickY []int + _screen *C.SCREEN + Default16 *ColorTheme + Dark256 *ColorTheme + Light256 *ColorTheme + FG int + CurrentFG int + BG int + DarkBG int +) + +func init() { + _prevDownTime = time.Unix(0, 0) + _clickY = []int{} + _colorMap = make(map[int]int) + Default16 = &ColorTheme{ + UseDefault: true, + Fg: 15, + Bg: 0, + DarkBg: C.COLOR_BLACK, + Prompt: C.COLOR_BLUE, + Match: C.COLOR_GREEN, + Current: C.COLOR_YELLOW, + CurrentMatch: C.COLOR_GREEN, + Spinner: C.COLOR_GREEN, + Info: C.COLOR_WHITE, + Cursor: C.COLOR_RED, + Selected: C.COLOR_MAGENTA, + Header: C.COLOR_CYAN} + Dark256 = &ColorTheme{ + UseDefault: true, + Fg: 15, + Bg: 0, + DarkBg: 236, + Prompt: 110, + Match: 108, + Current: 254, + CurrentMatch: 151, + Spinner: 148, + Info: 144, + Cursor: 161, + Selected: 168, + Header: 109} + Light256 = &ColorTheme{ + UseDefault: true, + Fg: 15, + Bg: 0, + DarkBg: 251, + Prompt: 25, + Match: 66, + Current: 237, + CurrentMatch: 23, + Spinner: 65, + Info: 101, + Cursor: 161, + Selected: 168, + Header: 31} +} + +func attrColored(pair int, bold bool) C.int { + var attr C.int + if pair > ColNormal { + attr = C.COLOR_PAIR(C.int(pair)) + } + if bold { + attr = attr | C.A_BOLD + } + return attr +} + +func attrMono(pair int, bold bool) C.int { + var attr C.int + switch pair { + case ColCurrent: + if bold { + attr = C.A_REVERSE + } + case ColMatch: + attr = C.A_UNDERLINE + case ColCurrentMatch: + attr = C.A_UNDERLINE | C.A_REVERSE + } + if bold { + attr = attr | C.A_BOLD + } + return attr +} + +func MaxX() int { + return int(C.COLS) +} + +func MaxY() int { + return int(C.LINES) +} + +func getch(nonblock bool) int { + b := make([]byte, 1) + syscall.SetNonblock(int(_in.Fd()), nonblock) + _, err := _in.Read(b) + if err != nil { + return -1 + } + return int(b[0]) +} + +func Init(theme *ColorTheme, black bool, mouse bool) { + { + in, err := os.OpenFile("/dev/tty", syscall.O_RDONLY, 0) + if err != nil { + panic("Failed to open /dev/tty") + } + _in = in + // Break STDIN + // syscall.Dup2(int(in.Fd()), int(os.Stdin.Fd())) + } + + C.setlocale(C.LC_ALL, C.CString("")) + _screen = C.newterm(nil, C.stderr, C.stdin) + if _screen == nil { + fmt.Println("Invalid $TERM: " + os.Getenv("TERM")) + os.Exit(2) + } + C.set_term(_screen) + if mouse { + C.mousemask(C.ALL_MOUSE_EVENTS, nil) + } + C.noecho() + C.raw() // stty dsusp undef + + intChan := make(chan os.Signal, 1) + signal.Notify(intChan, os.Interrupt, os.Kill) + go func() { + <-intChan + Close() + os.Exit(2) + }() + + if theme != nil { + C.start_color() + initPairs(theme, black) + _color = attrColored + } else { + _color = attrMono + } +} + +func initPairs(theme *ColorTheme, black bool) { + fg := C.short(theme.Fg) + bg := C.short(theme.Bg) + if black { + bg = C.COLOR_BLACK + } else if theme.UseDefault { + fg = -1 + bg = -1 + C.use_default_colors() + } + if theme.UseDefault { + FG = -1 + BG = -1 + } else { + FG = int(fg) + BG = int(bg) + C.assume_default_colors(C.int(theme.Fg), C.int(bg)) + } + + CurrentFG = int(theme.Current) + DarkBG = int(theme.DarkBg) + darkBG := C.short(DarkBG) + C.init_pair(ColPrompt, C.short(theme.Prompt), bg) + C.init_pair(ColMatch, C.short(theme.Match), bg) + C.init_pair(ColCurrent, C.short(theme.Current), darkBG) + C.init_pair(ColCurrentMatch, C.short(theme.CurrentMatch), darkBG) + C.init_pair(ColSpinner, C.short(theme.Spinner), bg) + C.init_pair(ColInfo, C.short(theme.Info), bg) + C.init_pair(ColCursor, C.short(theme.Cursor), darkBG) + C.init_pair(ColSelected, C.short(theme.Selected), darkBG) + C.init_pair(ColHeader, C.short(theme.Header), bg) +} + +func Close() { + C.endwin() + C.delscreen(_screen) +} + +func GetBytes() []byte { + c := getch(false) + _buf = append(_buf, byte(c)) + + for { + c = getch(true) + if c == -1 { + break + } + _buf = append(_buf, byte(c)) + } + + return _buf +} + +// 27 (91 79) 77 type x y +func mouseSequence(sz *int) Event { + if len(_buf) < 6 { + return Event{Invalid, 0, nil} + } + *sz = 6 + switch _buf[3] { + case 32, 36, 40, 48, // mouse-down / shift / cmd / ctrl + 35, 39, 43, 51: // mouse-up / shift / cmd / ctrl + mod := _buf[3] >= 36 + down := _buf[3]%2 == 0 + x := int(_buf[4] - 33) + y := int(_buf[5] - 33) + double := false + if down { + now := time.Now() + if now.Sub(_prevDownTime) < doubleClickDuration { + _clickY = append(_clickY, y) + } else { + _clickY = []int{y} + } + _prevDownTime = now + } else { + if len(_clickY) > 1 && _clickY[0] == _clickY[1] && + time.Now().Sub(_prevDownTime) < doubleClickDuration { + double = true + } + } + return Event{Mouse, 0, &MouseEvent{y, x, 0, down, double, mod}} + case 96, 100, 104, 112, // scroll-up / shift / cmd / ctrl + 97, 101, 105, 113: // scroll-down / shift / cmd / ctrl + mod := _buf[3] >= 100 + s := 1 - int(_buf[3]%2)*2 + return Event{Mouse, 0, &MouseEvent{0, 0, s, false, false, mod}} + } + return Event{Invalid, 0, nil} +} + +func escSequence(sz *int) Event { + if len(_buf) < 2 { + return Event{ESC, 0, nil} + } + *sz = 2 + switch _buf[1] { + case 98: + return Event{AltB, 0, nil} + case 100: + return Event{AltD, 0, nil} + case 102: + return Event{AltF, 0, nil} + case 127: + return Event{AltBS, 0, nil} + case 91, 79: + if len(_buf) < 3 { + return Event{Invalid, 0, nil} + } + *sz = 3 + switch _buf[2] { + case 68: + return Event{Left, 0, nil} + case 67: + return Event{Right, 0, nil} + case 66: + return Event{Down, 0, nil} + case 65: + return Event{Up, 0, nil} + case 90: + return Event{BTab, 0, nil} + case 72: + return Event{Home, 0, nil} + case 70: + return Event{End, 0, nil} + case 77: + return mouseSequence(sz) + case 80: + return Event{F1, 0, nil} + case 81: + return Event{F2, 0, nil} + case 82: + return Event{F3, 0, nil} + case 83: + return Event{F4, 0, nil} + case 49, 50, 51, 52, 53, 54: + if len(_buf) < 4 { + return Event{Invalid, 0, nil} + } + *sz = 4 + switch _buf[2] { + case 50: + return Event{Invalid, 0, nil} // INS + case 51: + return Event{Del, 0, nil} + case 52: + return Event{End, 0, nil} + case 53: + return Event{PgUp, 0, nil} + case 54: + return Event{PgDn, 0, nil} + case 49: + switch _buf[3] { + case 126: + return Event{Home, 0, nil} + case 59: + if len(_buf) != 6 { + return Event{Invalid, 0, nil} + } + *sz = 6 + switch _buf[4] { + case 50: + switch _buf[5] { + case 68: + return Event{Home, 0, nil} + case 67: + return Event{End, 0, nil} + } + case 53: + switch _buf[5] { + case 68: + return Event{SLeft, 0, nil} + case 67: + return Event{SRight, 0, nil} + } + } // _buf[4] + } // _buf[3] + } // _buf[2] + } // _buf[2] + } // _buf[1] + if _buf[1] >= 'a' && _buf[1] <= 'z' { + return Event{AltA + int(_buf[1]) - 'a', 0, nil} + } + return Event{Invalid, 0, nil} +} + +func GetChar() Event { + if len(_buf) == 0 { + _buf = GetBytes() + } + if len(_buf) == 0 { + panic("Empty _buffer") + } + + sz := 1 + defer func() { + _buf = _buf[sz:] + }() + + switch _buf[0] { + case CtrlC: + return Event{CtrlC, 0, nil} + case CtrlG: + return Event{CtrlG, 0, nil} + case CtrlQ: + return Event{CtrlQ, 0, nil} + case 127: + return Event{BSpace, 0, nil} + case ESC: + return escSequence(&sz) + } + + // CTRL-A ~ CTRL-Z + if _buf[0] <= CtrlZ { + return Event{int(_buf[0]), 0, nil} + } + r, rsz := utf8.DecodeRune(_buf) + if r == utf8.RuneError { + return Event{ESC, 0, nil} + } + sz = rsz + return Event{Rune, r, nil} +} + +func Move(y int, x int) { + C.move(C.int(y), C.int(x)) +} + +func MoveAndClear(y int, x int) { + Move(y, x) + C.clrtoeol() +} + +func Print(text string) { + C.addstr(C.CString(text)) +} + +func CPrint(pair int, bold bool, text string) { + attr := _color(pair, bold) + C.attron(attr) + Print(text) + C.attroff(attr) +} + +func Clear() { + C.clear() +} + +func Endwin() { + C.endwin() +} + +func Refresh() { + C.refresh() +} + +func PairFor(fg int, bg int) int { + key := (fg << 8) + bg + if found, prs := _colorMap[key]; prs { + return found + } + + id := len(_colorMap) + ColUser + C.init_pair(C.short(id), C.short(fg), C.short(bg)) + _colorMap[key] = id + return id +} diff --git a/src/curses/curses_test.go b/src/curses/curses_test.go new file mode 100644 index 0000000..db75c40 --- /dev/null +++ b/src/curses/curses_test.go @@ -0,0 +1,14 @@ +package curses + +import ( + "testing" +) + +func TestPairFor(t *testing.T) { + if PairFor(30, 50) != PairFor(30, 50) { + t.Fail() + } + if PairFor(-1, 10) != PairFor(-1, 10) { + t.Fail() + } +} diff --git a/src/fzf/main.go b/src/fzf/main.go new file mode 100644 index 0000000..29d4767 --- /dev/null +++ b/src/fzf/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/junegunn/fzf/src" + +func main() { + fzf.Run(fzf.ParseOptions()) +} diff --git a/src/history.go b/src/history.go new file mode 100644 index 0000000..4aa87fc --- /dev/null +++ b/src/history.go @@ -0,0 +1,96 @@ +package fzf + +import ( + "errors" + "io/ioutil" + "os" + "strings" +) + +// History struct represents input history +type History struct { + path string + lines []string + modified map[int]string + maxSize int + cursor int +} + +// NewHistory returns the pointer to a new History struct +func NewHistory(path string, maxSize int) (*History, error) { + fmtError := func(e error) error { + if os.IsPermission(e) { + return errors.New("permission denied: " + path) + } + return errors.New("invalid history file: " + e.Error()) + } + + // Read history file + data, err := ioutil.ReadFile(path) + if err != nil { + // If it doesn't exist, check if we can create a file with the name + if os.IsNotExist(err) { + data = []byte{} + if err := ioutil.WriteFile(path, data, 0600); err != nil { + return nil, fmtError(err) + } + } else { + return nil, fmtError(err) + } + } + // Split lines and limit the maximum number of lines + lines := strings.Split(strings.Trim(string(data), "\n"), "\n") + if len(lines[len(lines)-1]) > 0 { + lines = append(lines, "") + } + return &History{ + path: path, + maxSize: maxSize, + lines: lines, + modified: make(map[int]string), + cursor: len(lines) - 1}, nil +} + +func (h *History) append(line string) error { + // We don't append empty lines + if len(line) == 0 { + return nil + } + + lines := append(h.lines[:len(h.lines)-1], line) + if len(lines) > h.maxSize { + lines = lines[len(lines)-h.maxSize : len(lines)] + } + h.lines = append(lines, "") + return ioutil.WriteFile(h.path, []byte(strings.Join(h.lines, "\n")), 0600) +} + +func (h *History) override(str string) { + // You can update the history but they're not written to the file + if h.cursor == len(h.lines)-1 { + h.lines[h.cursor] = str + } else if h.cursor < len(h.lines)-1 { + h.modified[h.cursor] = str + } +} + +func (h *History) current() string { + if str, prs := h.modified[h.cursor]; prs { + return str + } + return h.lines[h.cursor] +} + +func (h *History) previous() string { + if h.cursor > 0 { + h.cursor-- + } + return h.current() +} + +func (h *History) next() string { + if h.cursor < len(h.lines)-1 { + h.cursor++ + } + return h.current() +} diff --git a/src/history_test.go b/src/history_test.go new file mode 100644 index 0000000..fa6f106 --- /dev/null +++ b/src/history_test.go @@ -0,0 +1,59 @@ +package fzf + +import ( + "os/user" + "testing" +) + +func TestHistory(t *testing.T) { + maxHistory := 50 + + // Invalid arguments + user, _ := user.Current() + paths := []string{"/etc", "/proc"} + if user.Name != "root" { + paths = append(paths, "/etc/sudoers") + } + for _, path := range paths { + if _, e := NewHistory(path, maxHistory); e == nil { + t.Error("Error expected for: " + path) + } + } + { // Append lines + h, _ := NewHistory("/tmp/fzf-history", maxHistory) + for i := 0; i < maxHistory+10; i++ { + h.append("foobar") + } + } + { // Read lines + h, _ := NewHistory("/tmp/fzf-history", maxHistory) + if len(h.lines) != maxHistory+1 { + t.Errorf("Expected: %d, actual: %d\n", maxHistory+1, len(h.lines)) + } + for i := 0; i < maxHistory; i++ { + if h.lines[i] != "foobar" { + t.Error("Expected: foobar, actual: " + h.lines[i]) + } + } + } + { // Append lines + h, _ := NewHistory("/tmp/fzf-history", maxHistory) + h.append("barfoo") + h.append("") + h.append("foobarbaz") + } + { // Read lines again + h, _ := NewHistory("/tmp/fzf-history", maxHistory) + if len(h.lines) != maxHistory+1 { + t.Errorf("Expected: %d, actual: %d\n", maxHistory+1, len(h.lines)) + } + compare := func(idx int, exp string) { + if h.lines[idx] != exp { + t.Errorf("Expected: %s, actual: %s\n", exp, h.lines[idx]) + } + } + compare(maxHistory-3, "foobar") + compare(maxHistory-2, "barfoo") + compare(maxHistory-1, "foobarbaz") + } +} diff --git a/src/item.go b/src/item.go new file mode 100644 index 0000000..12ca3df --- /dev/null +++ b/src/item.go @@ -0,0 +1,267 @@ +package fzf + +import ( + "math" + + "github.com/junegunn/fzf/src/curses" +) + +// Offset holds two 32-bit integers denoting the offsets of a matched substring +type Offset [2]int32 + +type colorOffset struct { + offset [2]int32 + color int + bold bool +} + +// Item represents each input line +type Item struct { + text []rune + origText *[]rune + transformed []Token + index uint32 + offsets []Offset + colors []ansiOffset + rank Rank +} + +// Rank is used to sort the search result +type Rank struct { + matchlen uint16 + tiebreak uint16 + index uint32 +} + +// Tiebreak criterion to use. Never changes once fzf is started. +var rankTiebreak tiebreak + +// Rank calculates rank of the Item +func (item *Item) Rank(cache bool) Rank { + if cache && (item.rank.matchlen > 0 || item.rank.tiebreak > 0) { + return item.rank + } + matchlen := 0 + prevEnd := 0 + minBegin := math.MaxUint16 + for _, offset := range item.offsets { + begin := int(offset[0]) + end := int(offset[1]) + if prevEnd > begin { + begin = prevEnd + } + if end > prevEnd { + prevEnd = end + } + if end > begin { + if begin < minBegin { + minBegin = begin + } + matchlen += end - begin + } + } + var tiebreak uint16 + switch rankTiebreak { + case byLength: + // It is guaranteed that .transformed in not null in normal execution + if item.transformed != nil { + lenSum := 0 + for _, token := range item.transformed { + lenSum += len(token.text) + } + tiebreak = uint16(lenSum) + } else { + tiebreak = uint16(len(item.text)) + } + case byBegin: + // We can't just look at item.offsets[0][0] because it can be an inverse term + tiebreak = uint16(minBegin) + case byEnd: + if prevEnd > 0 { + tiebreak = uint16(1 + len(item.text) - prevEnd) + } else { + // Empty offsets due to inverse terms. + tiebreak = 1 + } + case byIndex: + tiebreak = 1 + } + rank := Rank{uint16(matchlen), tiebreak, item.index} + if cache { + item.rank = rank + } + return rank +} + +// AsString returns the original string +func (item *Item) AsString(stripAnsi bool) string { + return *item.StringPtr(stripAnsi) +} + +// StringPtr returns the pointer to the original string +func (item *Item) StringPtr(stripAnsi bool) *string { + if item.origText != nil { + if stripAnsi { + trimmed, _, _ := extractColor(string(*item.origText), nil) + return &trimmed + } + orig := string(*item.origText) + return &orig + } + str := string(item.text) + return &str +} + +func (item *Item) colorOffsets(color int, bold bool, current bool) []colorOffset { + if len(item.colors) == 0 { + var offsets []colorOffset + for _, off := range item.offsets { + offsets = append(offsets, colorOffset{offset: off, color: color, bold: bold}) + } + return offsets + } + + // Find max column + var maxCol int32 + for _, off := range item.offsets { + if off[1] > maxCol { + maxCol = off[1] + } + } + for _, ansi := range item.colors { + if ansi.offset[1] > maxCol { + maxCol = ansi.offset[1] + } + } + cols := make([]int, maxCol) + + for colorIndex, ansi := range item.colors { + for i := ansi.offset[0]; i < ansi.offset[1]; i++ { + cols[i] = colorIndex + 1 // XXX + } + } + + for _, off := range item.offsets { + for i := off[0]; i < off[1]; i++ { + cols[i] = -1 + } + } + + // sort.Sort(ByOrder(offsets)) + + // Merge offsets + // ------------ ---- -- ---- + // ++++++++ ++++++++++ + // --++++++++-- --++++++++++--- + curr := 0 + start := 0 + var offsets []colorOffset + add := func(idx int) { + if curr != 0 && idx > start { + if curr == -1 { + offsets = append(offsets, colorOffset{ + offset: Offset{int32(start), int32(idx)}, color: color, bold: bold}) + } else { + ansi := item.colors[curr-1] + fg := ansi.color.fg + if fg == -1 { + if current { + fg = curses.CurrentFG + } else { + fg = curses.FG + } + } + bg := ansi.color.bg + if bg == -1 { + if current { + bg = curses.DarkBG + } else { + bg = curses.BG + } + } + offsets = append(offsets, colorOffset{ + offset: Offset{int32(start), int32(idx)}, + color: curses.PairFor(fg, bg), + bold: ansi.color.bold || bold}) + } + } + } + for idx, col := range cols { + if col != curr { + add(idx) + start = idx + curr = col + } + } + add(int(maxCol)) + return offsets +} + +// ByOrder is for sorting substring offsets +type ByOrder []Offset + +func (a ByOrder) Len() int { + return len(a) +} + +func (a ByOrder) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a ByOrder) Less(i, j int) bool { + ioff := a[i] + joff := a[j] + return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1]) +} + +// ByRelevance is for sorting Items +type ByRelevance []*Item + +func (a ByRelevance) Len() int { + return len(a) +} + +func (a ByRelevance) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a ByRelevance) Less(i, j int) bool { + irank := a[i].Rank(true) + jrank := a[j].Rank(true) + + return compareRanks(irank, jrank, false) +} + +// ByRelevanceTac is for sorting Items +type ByRelevanceTac []*Item + +func (a ByRelevanceTac) Len() int { + return len(a) +} + +func (a ByRelevanceTac) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a ByRelevanceTac) Less(i, j int) bool { + irank := a[i].Rank(true) + jrank := a[j].Rank(true) + + return compareRanks(irank, jrank, true) +} + +func compareRanks(irank Rank, jrank Rank, tac bool) bool { + if irank.matchlen < jrank.matchlen { + return true + } else if irank.matchlen > jrank.matchlen { + return false + } + + if irank.tiebreak < jrank.tiebreak { + return true + } else if irank.tiebreak > jrank.tiebreak { + return false + } + + return (irank.index <= jrank.index) != tac +} diff --git a/src/item_test.go b/src/item_test.go new file mode 100644 index 0000000..5b9232a --- /dev/null +++ b/src/item_test.go @@ -0,0 +1,104 @@ +package fzf + +import ( + "sort" + "testing" + + "github.com/junegunn/fzf/src/curses" +) + +func TestOffsetSort(t *testing.T) { + offsets := []Offset{ + Offset{3, 5}, Offset{2, 7}, + Offset{1, 3}, Offset{2, 9}} + sort.Sort(ByOrder(offsets)) + + if offsets[0][0] != 1 || offsets[0][1] != 3 || + offsets[1][0] != 2 || offsets[1][1] != 7 || + offsets[2][0] != 2 || offsets[2][1] != 9 || + offsets[3][0] != 3 || offsets[3][1] != 5 { + t.Error("Invalid order:", offsets) + } +} + +func TestRankComparison(t *testing.T) { + if compareRanks(Rank{3, 0, 5}, Rank{2, 0, 7}, false) || + !compareRanks(Rank{3, 0, 5}, Rank{3, 0, 6}, false) || + !compareRanks(Rank{1, 2, 3}, Rank{1, 3, 2}, false) || + !compareRanks(Rank{0, 0, 0}, Rank{0, 0, 0}, false) { + t.Error("Invalid order") + } + + if compareRanks(Rank{3, 0, 5}, Rank{2, 0, 7}, true) || + !compareRanks(Rank{3, 0, 5}, Rank{3, 0, 6}, false) || + !compareRanks(Rank{1, 2, 3}, Rank{1, 3, 2}, true) || + !compareRanks(Rank{0, 0, 0}, Rank{0, 0, 0}, false) { + t.Error("Invalid order (tac)") + } +} + +// Match length, string length, index +func TestItemRank(t *testing.T) { + strs := [][]rune{[]rune("foo"), []rune("foobar"), []rune("bar"), []rune("baz")} + item1 := Item{text: strs[0], index: 1, offsets: []Offset{}} + rank1 := item1.Rank(true) + if rank1.matchlen != 0 || rank1.tiebreak != 3 || rank1.index != 1 { + t.Error(item1.Rank(true)) + } + // Only differ in index + item2 := Item{text: strs[0], index: 0, offsets: []Offset{}} + + items := []*Item{&item1, &item2} + sort.Sort(ByRelevance(items)) + if items[0] != &item2 || items[1] != &item1 { + t.Error(items) + } + + items = []*Item{&item2, &item1, &item1, &item2} + sort.Sort(ByRelevance(items)) + if items[0] != &item2 || items[1] != &item2 || + items[2] != &item1 || items[3] != &item1 { + t.Error(items) + } + + // Sort by relevance + item3 := Item{text: strs[1], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 3}, Offset{5, 7}}} + item4 := Item{text: strs[1], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 2}, Offset{6, 7}}} + item5 := Item{text: strs[2], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 3}, Offset{5, 7}}} + item6 := Item{text: strs[2], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 2}, Offset{6, 7}}} + items = []*Item{&item1, &item2, &item3, &item4, &item5, &item6} + sort.Sort(ByRelevance(items)) + if items[0] != &item2 || items[1] != &item1 || + items[2] != &item6 || items[3] != &item4 || + items[4] != &item5 || items[5] != &item3 { + t.Error(items) + } +} + +func TestColorOffset(t *testing.T) { + // ------------ 20 ---- -- ---- + // ++++++++ ++++++++++ + // --++++++++-- --++++++++++--- + item := Item{ + offsets: []Offset{Offset{5, 15}, Offset{25, 35}}, + colors: []ansiOffset{ + ansiOffset{[2]int32{0, 20}, ansiState{1, 5, false}}, + ansiOffset{[2]int32{22, 27}, ansiState{2, 6, true}}, + ansiOffset{[2]int32{30, 32}, ansiState{3, 7, false}}, + ansiOffset{[2]int32{33, 40}, ansiState{4, 8, true}}}} + // [{[0 5] 9 false} {[5 15] 99 false} {[15 20] 9 false} {[22 25] 10 true} {[25 35] 99 false} {[35 40] 11 true}] + + offsets := item.colorOffsets(99, false, true) + assert := func(idx int, b int32, e int32, c int, bold bool) { + o := offsets[idx] + if o.offset[0] != b || o.offset[1] != e || o.color != c || o.bold != bold { + t.Error(o) + } + } + assert(0, 0, 5, curses.ColUser, false) + assert(1, 5, 15, 99, false) + assert(2, 15, 20, curses.ColUser, false) + assert(3, 22, 25, curses.ColUser+1, true) + assert(4, 25, 35, 99, false) + assert(5, 35, 40, curses.ColUser+2, true) +} diff --git a/src/matcher.go b/src/matcher.go new file mode 100644 index 0000000..3ea2fbe --- /dev/null +++ b/src/matcher.go @@ -0,0 +1,221 @@ +package fzf + +import ( + "fmt" + "runtime" + "sort" + "sync" + "time" + + "github.com/junegunn/fzf/src/util" +) + +// MatchRequest represents a search request +type MatchRequest struct { + chunks []*Chunk + pattern *Pattern + final bool + sort bool +} + +// Matcher is responsible for performing search +type Matcher struct { + patternBuilder func([]rune) *Pattern + sort bool + tac bool + eventBox *util.EventBox + reqBox *util.EventBox + partitions int + mergerCache map[string]*Merger +} + +const ( + reqRetry util.EventType = iota + reqReset +) + +// NewMatcher returns a new Matcher +func NewMatcher(patternBuilder func([]rune) *Pattern, + sort bool, tac bool, eventBox *util.EventBox) *Matcher { + return &Matcher{ + patternBuilder: patternBuilder, + sort: sort, + tac: tac, + eventBox: eventBox, + reqBox: util.NewEventBox(), + partitions: runtime.NumCPU(), + mergerCache: make(map[string]*Merger)} +} + +// Loop puts Matcher in action +func (m *Matcher) Loop() { + prevCount := 0 + + for { + var request MatchRequest + + m.reqBox.Wait(func(events *util.Events) { + for _, val := range *events { + switch val := val.(type) { + case MatchRequest: + request = val + default: + panic(fmt.Sprintf("Unexpected type: %T", val)) + } + } + events.Clear() + }) + + if request.sort != m.sort { + m.sort = request.sort + m.mergerCache = make(map[string]*Merger) + clearChunkCache() + } + + // Restart search + patternString := request.pattern.AsString() + var merger *Merger + cancelled := false + count := CountItems(request.chunks) + + foundCache := false + if count == prevCount { + // Look up mergerCache + if cached, found := m.mergerCache[patternString]; found { + foundCache = true + merger = cached + } + } else { + // Invalidate mergerCache + prevCount = count + m.mergerCache = make(map[string]*Merger) + } + + if !foundCache { + merger, cancelled = m.scan(request) + } + + if !cancelled { + if merger.cacheable() { + m.mergerCache[patternString] = merger + } + merger.final = request.final + m.eventBox.Set(EvtSearchFin, merger) + } + } +} + +func (m *Matcher) sliceChunks(chunks []*Chunk) [][]*Chunk { + perSlice := len(chunks) / m.partitions + + // No need to parallelize + if perSlice == 0 { + return [][]*Chunk{chunks} + } + + slices := make([][]*Chunk, m.partitions) + for i := 0; i < m.partitions; i++ { + start := i * perSlice + end := start + perSlice + if i == m.partitions-1 { + end = len(chunks) + } + slices[i] = chunks[start:end] + } + return slices +} + +type partialResult struct { + index int + matches []*Item +} + +func (m *Matcher) scan(request MatchRequest) (*Merger, bool) { + startedAt := time.Now() + + numChunks := len(request.chunks) + if numChunks == 0 { + return EmptyMerger, false + } + pattern := request.pattern + if pattern.IsEmpty() { + return PassMerger(&request.chunks, m.tac), false + } + + cancelled := util.NewAtomicBool(false) + + slices := m.sliceChunks(request.chunks) + numSlices := len(slices) + resultChan := make(chan partialResult, numSlices) + countChan := make(chan int, numChunks) + waitGroup := sync.WaitGroup{} + + for idx, chunks := range slices { + waitGroup.Add(1) + go func(idx int, chunks []*Chunk) { + defer func() { waitGroup.Done() }() + sliceMatches := []*Item{} + for _, chunk := range chunks { + matches := request.pattern.Match(chunk) + sliceMatches = append(sliceMatches, matches...) + if cancelled.Get() { + return + } + countChan <- len(matches) + } + if m.sort { + if m.tac { + sort.Sort(ByRelevanceTac(sliceMatches)) + } else { + sort.Sort(ByRelevance(sliceMatches)) + } + } + resultChan <- partialResult{idx, sliceMatches} + }(idx, chunks) + } + + wait := func() bool { + cancelled.Set(true) + waitGroup.Wait() + return true + } + + count := 0 + matchCount := 0 + for matchesInChunk := range countChan { + count++ + matchCount += matchesInChunk + + if count == numChunks { + break + } + + if m.reqBox.Peek(reqReset) { + return nil, wait() + } + + if time.Now().Sub(startedAt) > progressMinDuration { + m.eventBox.Set(EvtSearchProgress, float32(count)/float32(numChunks)) + } + } + + partialResults := make([][]*Item, numSlices) + for range slices { + partialResult := <-resultChan + partialResults[partialResult.index] = partialResult.matches + } + return NewMerger(partialResults, m.sort, m.tac), false +} + +// Reset is called to interrupt/signal the ongoing search +func (m *Matcher) Reset(chunks []*Chunk, patternRunes []rune, cancel bool, final bool, sort bool) { + pattern := m.patternBuilder(patternRunes) + + var event util.EventType + if cancel { + event = reqReset + } else { + event = reqRetry + } + m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort}) +} diff --git a/src/merger.go b/src/merger.go new file mode 100644 index 0000000..cce8a94 --- /dev/null +++ b/src/merger.go @@ -0,0 +1,118 @@ +package fzf + +import "fmt" + +// Merger with no data +var EmptyMerger = NewMerger([][]*Item{}, false, false) + +// Merger holds a set of locally sorted lists of items and provides the view of +// a single, globally-sorted list +type Merger struct { + lists [][]*Item + merged []*Item + chunks *[]*Chunk + cursors []int + sorted bool + tac bool + final bool + count int +} + +// PassMerger returns a new Merger that simply returns the items in the +// original order +func PassMerger(chunks *[]*Chunk, tac bool) *Merger { + mg := Merger{ + chunks: chunks, + tac: tac, + count: 0} + + for _, chunk := range *mg.chunks { + mg.count += len(*chunk) + } + return &mg +} + +// NewMerger returns a new Merger +func NewMerger(lists [][]*Item, sorted bool, tac bool) *Merger { + mg := Merger{ + lists: lists, + merged: []*Item{}, + chunks: nil, + cursors: make([]int, len(lists)), + sorted: sorted, + tac: tac, + final: false, + count: 0} + + for _, list := range mg.lists { + mg.count += len(list) + } + return &mg +} + +// Length returns the number of items +func (mg *Merger) Length() int { + return mg.count +} + +// Get returns the pointer to the Item object indexed by the given integer +func (mg *Merger) Get(idx int) *Item { + if mg.chunks != nil { + if mg.tac { + idx = mg.count - idx - 1 + } + chunk := (*mg.chunks)[idx/chunkSize] + return (*chunk)[idx%chunkSize] + } + + if mg.sorted { + return mg.mergedGet(idx) + } + + if mg.tac { + idx = mg.count - idx - 1 + } + for _, list := range mg.lists { + numItems := len(list) + if idx < numItems { + return list[idx] + } + idx -= numItems + } + panic(fmt.Sprintf("Index out of bounds (unsorted, %d/%d)", idx, mg.count)) +} + +func (mg *Merger) cacheable() bool { + return mg.count < mergerCacheMax +} + +func (mg *Merger) mergedGet(idx int) *Item { + for i := len(mg.merged); i <= idx; i++ { + minRank := Rank{0, 0, 0} + minIdx := -1 + for listIdx, list := range mg.lists { + cursor := mg.cursors[listIdx] + if cursor < 0 || cursor == len(list) { + mg.cursors[listIdx] = -1 + continue + } + if cursor >= 0 { + rank := list[cursor].Rank(false) + if minIdx < 0 || compareRanks(rank, minRank, mg.tac) { + minRank = rank + minIdx = listIdx + } + } + mg.cursors[listIdx] = cursor + } + + if minIdx >= 0 { + chosen := mg.lists[minIdx] + mg.merged = append(mg.merged, chosen[mg.cursors[minIdx]]) + mg.cursors[minIdx]++ + } else { + panic(fmt.Sprintf("Index out of bounds (sorted, %d/%d)", i, mg.count)) + } + } + return mg.merged[idx] +} diff --git a/src/merger_test.go b/src/merger_test.go new file mode 100644 index 0000000..b7a2993 --- /dev/null +++ b/src/merger_test.go @@ -0,0 +1,93 @@ +package fzf + +import ( + "fmt" + "math/rand" + "sort" + "testing" +) + +func assert(t *testing.T, cond bool, msg ...string) { + if !cond { + t.Error(msg) + } +} + +func randItem() *Item { + str := fmt.Sprintf("%d", rand.Uint32()) + offsets := make([]Offset, rand.Int()%3) + for idx := range offsets { + sidx := int32(rand.Uint32() % 20) + eidx := sidx + int32(rand.Uint32()%20) + offsets[idx] = Offset{sidx, eidx} + } + return &Item{ + text: []rune(str), + index: rand.Uint32(), + offsets: offsets} +} + +func TestEmptyMerger(t *testing.T) { + assert(t, EmptyMerger.Length() == 0, "Not empty") + assert(t, EmptyMerger.count == 0, "Invalid count") + assert(t, len(EmptyMerger.lists) == 0, "Invalid lists") + assert(t, len(EmptyMerger.merged) == 0, "Invalid merged list") +} + +func buildLists(partiallySorted bool) ([][]*Item, []*Item) { + numLists := 4 + lists := make([][]*Item, numLists) + cnt := 0 + for i := 0; i < numLists; i++ { + numItems := rand.Int() % 20 + cnt += numItems + lists[i] = make([]*Item, numItems) + for j := 0; j < numItems; j++ { + item := randItem() + lists[i][j] = item + } + if partiallySorted { + sort.Sort(ByRelevance(lists[i])) + } + } + items := []*Item{} + for _, list := range lists { + items = append(items, list...) + } + return lists, items +} + +func TestMergerUnsorted(t *testing.T) { + lists, items := buildLists(false) + cnt := len(items) + + // Not sorted: same order + mg := NewMerger(lists, false, false) + assert(t, cnt == mg.Length(), "Invalid Length") + for i := 0; i < cnt; i++ { + assert(t, items[i] == mg.Get(i), "Invalid Get") + } +} + +func TestMergerSorted(t *testing.T) { + lists, items := buildLists(true) + cnt := len(items) + + // Sorted sorted order + mg := NewMerger(lists, true, false) + assert(t, cnt == mg.Length(), "Invalid Length") + sort.Sort(ByRelevance(items)) + for i := 0; i < cnt; i++ { + if items[i] != mg.Get(i) { + t.Error("Not sorted", items[i], mg.Get(i)) + } + } + + // Inverse order + mg2 := NewMerger(lists, true, false) + for i := cnt - 1; i >= 0; i-- { + if items[i] != mg2.Get(i) { + t.Error("Not sorted", items[i], mg2.Get(i)) + } + } +} diff --git a/src/options.go b/src/options.go new file mode 100644 index 0000000..9d8aaa1 --- /dev/null +++ b/src/options.go @@ -0,0 +1,892 @@ +package fzf + +import ( + "os" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/junegunn/fzf/src/curses" + + "github.com/junegunn/go-shellwords" +) + +const usage = `usage: fzf [options] + + Search + -x, --extended Extended-search mode + -e, --extended-exact Extended-search mode (exact match) + -i Case-insensitive match (default: smart-case match) + +i Case-sensitive match + -n, --nth=N[,..] Comma-separated list of field index expressions + for limiting search scope. Each can be a non-zero + integer or a range expression ([BEGIN]..[END]) + --with-nth=N[,..] Transform item using index expressions within finder + -d, --delimiter=STR Field delimiter regex for --nth (default: AWK-style) + +s, --no-sort Do not sort the result + --tac Reverse the order of the input + --tiebreak=CRITERION Sort criterion when the scores are tied; + [length|begin|end|index] (default: length) + + Interface + -m, --multi Enable multi-select with tab/shift-tab + --ansi Enable processing of ANSI color codes + --no-mouse Disable mouse + --color=COLSPEC Base scheme (dark|light|16|bw) and/or custom colors + --black Use black background + --reverse Reverse orientation + --margin=MARGIN Screen margin (TRBL / TB,RL / T,RL,B / T,R,B,L) + --cycle Enable cyclic scroll + --no-hscroll Disable horizontal scroll + --inline-info Display finder info inline with the query + --prompt=STR Input prompt (default: '> ') + --bind=KEYBINDS Custom key bindings. Refer to the man page. + --history=FILE History file + --history-size=N Maximum number of history entries (default: 1000) + --header=STR String to print as header + --header-lines=N The first N lines of the input are treated as header + + Scripting + -q, --query=STR Start the finder with the given query + -1, --select-1 Automatically select the only match + -0, --exit-0 Exit immediately when there's no match + -f, --filter=STR Filter mode. Do not start interactive finder. + --print-query Print query as the first line + --expect=KEYS Comma-separated list of keys to complete fzf + --sync Synchronous search for multi-staged filtering + + Environment variables + FZF_DEFAULT_COMMAND Default command to use when input is tty + FZF_DEFAULT_OPTS Defaults options. (e.g. '-x -m') + +` + +// Mode denotes the current search mode +type Mode int + +// Search modes +const ( + ModeFuzzy Mode = iota + ModeExtended + ModeExtendedExact +) + +// Case denotes case-sensitivity of search +type Case int + +// Case-sensitivities +const ( + CaseSmart Case = iota + CaseIgnore + CaseRespect +) + +// Sort criteria +type tiebreak int + +const ( + byLength tiebreak = iota + byBegin + byEnd + byIndex +) + +func defaultMargin() [4]string { + return [4]string{"0", "0", "0", "0"} +} + +// Options stores the values of command-line options +type Options struct { + Mode Mode + Case Case + Nth []Range + WithNth []Range + Delimiter Delimiter + Sort int + Tac bool + Tiebreak tiebreak + Multi bool + Ansi bool + Mouse bool + Theme *curses.ColorTheme + Black bool + Reverse bool + Cycle bool + Hscroll bool + InlineInfo bool + Prompt string + Query string + Select1 bool + Exit0 bool + Filter *string + ToggleSort bool + Expect map[int]string + Keymap map[int]actionType + Execmap map[int]string + PrintQuery bool + ReadZero bool + Sync bool + History *History + Header []string + HeaderLines int + Margin [4]string + Version bool +} + +func defaultTheme() *curses.ColorTheme { + if strings.Contains(os.Getenv("TERM"), "256") { + return curses.Dark256 + } + return curses.Default16 +} + +func defaultOptions() *Options { + return &Options{ + Mode: ModeFuzzy, + Case: CaseSmart, + Nth: make([]Range, 0), + WithNth: make([]Range, 0), + Delimiter: Delimiter{}, + Sort: 1000, + Tac: false, + Tiebreak: byLength, + Multi: false, + Ansi: false, + Mouse: true, + Theme: defaultTheme(), + Black: false, + Reverse: false, + Cycle: false, + Hscroll: true, + InlineInfo: false, + Prompt: "> ", + Query: "", + Select1: false, + Exit0: false, + Filter: nil, + ToggleSort: false, + Expect: make(map[int]string), + Keymap: defaultKeymap(), + Execmap: make(map[int]string), + PrintQuery: false, + ReadZero: false, + Sync: false, + History: nil, + Header: make([]string, 0), + HeaderLines: 0, + Margin: defaultMargin(), + Version: false} +} + +func help(code int) { + os.Stderr.WriteString(usage) + os.Exit(code) +} + +func errorExit(msg string) { + os.Stderr.WriteString(msg + "\n") + os.Exit(exitError) +} + +func optString(arg string, prefixes ...string) (bool, string) { + for _, prefix := range prefixes { + if strings.HasPrefix(arg, prefix) { + return true, arg[len(prefix):] + } + } + return false, "" +} + +func nextString(args []string, i *int, message string) string { + if len(args) > *i+1 { + *i++ + } else { + errorExit(message) + } + return args[*i] +} + +func optionalNextString(args []string, i *int) string { + if len(args) > *i+1 { + *i++ + return args[*i] + } + return "" +} + +func atoi(str string) int { + num, err := strconv.Atoi(str) + if err != nil { + errorExit("not a valid integer: " + str) + } + return num +} + +func atof(str string) float64 { + num, err := strconv.ParseFloat(str, 64) + if err != nil { + errorExit("not a valid number: " + str) + } + return num +} + +func nextInt(args []string, i *int, message string) int { + if len(args) > *i+1 { + *i++ + } else { + errorExit(message) + } + return atoi(args[*i]) +} + +func optionalNumeric(args []string, i *int) int { + if len(args) > *i+1 { + if strings.IndexAny(args[*i+1], "0123456789") == 0 { + *i++ + } + } + return 1 // Don't care +} + +func splitNth(str string) []Range { + if match, _ := regexp.MatchString("^[0-9,-.]+$", str); !match { + errorExit("invalid format: " + str) + } + + tokens := strings.Split(str, ",") + ranges := make([]Range, len(tokens)) + for idx, s := range tokens { + r, ok := ParseRange(&s) + if !ok { + errorExit("invalid format: " + str) + } + ranges[idx] = r + } + return ranges +} + +func delimiterRegexp(str string) Delimiter { + // Special handling of \t + str = strings.Replace(str, "\\t", "\t", -1) + + // 1. Pattern does not contain any special character + if regexp.QuoteMeta(str) == str { + return Delimiter{str: &str} + } + + rx, e := regexp.Compile(str) + // 2. Pattern is not a valid regular expression + if e != nil { + return Delimiter{str: &str} + } + + // 3. Pattern as regular expression. Slow. + return Delimiter{regex: rx} +} + +func isAlphabet(char uint8) bool { + return char >= 'a' && char <= 'z' +} + +func parseKeyChords(str string, message string) map[int]string { + if len(str) == 0 { + errorExit(message) + } + + tokens := strings.Split(str, ",") + if str == "," || strings.HasPrefix(str, ",,") || strings.HasSuffix(str, ",,") || strings.Index(str, ",,,") >= 0 { + tokens = append(tokens, ",") + } + + chords := make(map[int]string) + for _, key := range tokens { + if len(key) == 0 { + continue // ignore + } + lkey := strings.ToLower(key) + chord := 0 + switch lkey { + case "up": + chord = curses.Up + case "down": + chord = curses.Down + case "left": + chord = curses.Left + case "right": + chord = curses.Right + case "enter", "return": + chord = curses.CtrlM + case "space": + chord = curses.AltZ + int(' ') + case "bspace", "bs": + chord = curses.BSpace + case "alt-bs", "alt-bspace": + chord = curses.AltBS + case "tab": + chord = curses.Tab + case "btab", "shift-tab": + chord = curses.BTab + case "esc": + chord = curses.ESC + case "del": + chord = curses.Del + case "home": + chord = curses.Home + case "end": + chord = curses.End + case "pgup", "page-up": + chord = curses.PgUp + case "pgdn", "page-down": + chord = curses.PgDn + case "shift-left": + chord = curses.SLeft + case "shift-right": + chord = curses.SRight + default: + if len(key) == 6 && strings.HasPrefix(lkey, "ctrl-") && isAlphabet(lkey[5]) { + chord = curses.CtrlA + int(lkey[5]) - 'a' + } else if len(key) == 5 && strings.HasPrefix(lkey, "alt-") && isAlphabet(lkey[4]) { + chord = curses.AltA + int(lkey[4]) - 'a' + } else if len(key) == 2 && strings.HasPrefix(lkey, "f") && key[1] >= '1' && key[1] <= '4' { + chord = curses.F1 + int(key[1]) - '1' + } else if utf8.RuneCountInString(key) == 1 { + chord = curses.AltZ + int([]rune(key)[0]) + } else { + errorExit("unsupported key: " + key) + } + } + if chord > 0 { + chords[chord] = key + } + } + return chords +} + +func parseTiebreak(str string) tiebreak { + switch strings.ToLower(str) { + case "length": + return byLength + case "index": + return byIndex + case "begin": + return byBegin + case "end": + return byEnd + default: + errorExit("invalid sort criterion: " + str) + } + return byLength +} + +func dupeTheme(theme *curses.ColorTheme) *curses.ColorTheme { + dupe := *theme + return &dupe +} + +func parseTheme(defaultTheme *curses.ColorTheme, str string) *curses.ColorTheme { + theme := dupeTheme(defaultTheme) + for _, str := range strings.Split(strings.ToLower(str), ",") { + switch str { + case "dark": + theme = dupeTheme(curses.Dark256) + case "light": + theme = dupeTheme(curses.Light256) + case "16": + theme = dupeTheme(curses.Default16) + case "bw", "no": + theme = nil + default: + fail := func() { + errorExit("invalid color specification: " + str) + } + // Color is disabled + if theme == nil { + errorExit("colors disabled; cannot customize colors") + } + + pair := strings.Split(str, ":") + if len(pair) != 2 { + fail() + } + ansi32, err := strconv.Atoi(pair[1]) + if err != nil || ansi32 < -1 || ansi32 > 255 { + fail() + } + ansi := int16(ansi32) + switch pair[0] { + case "fg": + theme.Fg = ansi + theme.UseDefault = theme.UseDefault && ansi < 0 + case "bg": + theme.Bg = ansi + theme.UseDefault = theme.UseDefault && ansi < 0 + case "fg+": + theme.Current = ansi + case "bg+": + theme.DarkBg = ansi + case "hl": + theme.Match = ansi + case "hl+": + theme.CurrentMatch = ansi + case "prompt": + theme.Prompt = ansi + case "spinner": + theme.Spinner = ansi + case "info": + theme.Info = ansi + case "pointer": + theme.Cursor = ansi + case "marker": + theme.Selected = ansi + case "header": + theme.Header = ansi + default: + fail() + } + } + } + return theme +} + +var executeRegexp *regexp.Regexp + +func firstKey(keymap map[int]string) int { + for k := range keymap { + return k + } + return 0 +} + +const ( + escapedColon = 0 + escapedComma = 1 +) + +func parseKeymap(keymap map[int]actionType, execmap map[int]string, toggleSort bool, str string) (map[int]actionType, map[int]string, bool) { + if executeRegexp == nil { + // Backreferences are not supported. + // "~!@#$%^&*;/|".each_char.map { |c| Regexp.escape(c) }.map { |c| "#{c}[^#{c}]*#{c}" }.join('|') + executeRegexp = regexp.MustCompile( + "(?s):execute:.*|:execute(\\([^)]*\\)|\\[[^\\]]*\\]|~[^~]*~|![^!]*!|@[^@]*@|\\#[^\\#]*\\#|\\$[^\\$]*\\$|%[^%]*%|\\^[^\\^]*\\^|&[^&]*&|\\*[^\\*]*\\*|;[^;]*;|/[^/]*/|\\|[^\\|]*\\|)") + } + masked := executeRegexp.ReplaceAllStringFunc(str, func(src string) string { + return ":execute(" + strings.Repeat(" ", len(src)-10) + ")" + }) + masked = strings.Replace(masked, "::", string([]rune{escapedColon, ':'}), -1) + masked = strings.Replace(masked, ",:", string([]rune{escapedComma, ':'}), -1) + + idx := 0 + for _, pairStr := range strings.Split(masked, ",") { + origPairStr := str[idx : idx+len(pairStr)] + idx += len(pairStr) + 1 + + pair := strings.SplitN(pairStr, ":", 2) + if len(pair) < 2 { + errorExit("bind action not specified: " + origPairStr) + } + var key int + if len(pair[0]) == 1 && pair[0][0] == escapedColon { + key = ':' + curses.AltZ + } else if len(pair[0]) == 1 && pair[0][0] == escapedComma { + key = ',' + curses.AltZ + } else { + keys := parseKeyChords(pair[0], "key name required") + key = firstKey(keys) + } + + act := origPairStr[len(pair[0])+1 : len(origPairStr)] + actLower := strings.ToLower(act) + switch actLower { + case "ignore": + keymap[key] = actIgnore + case "beginning-of-line": + keymap[key] = actBeginningOfLine + case "abort": + keymap[key] = actAbort + case "accept": + keymap[key] = actAccept + case "backward-char": + keymap[key] = actBackwardChar + case "backward-delete-char": + keymap[key] = actBackwardDeleteChar + case "backward-word": + keymap[key] = actBackwardWord + case "clear-screen": + keymap[key] = actClearScreen + case "delete-char": + keymap[key] = actDeleteChar + case "delete-char/eof": + keymap[key] = actDeleteCharEOF + case "end-of-line": + keymap[key] = actEndOfLine + case "cancel": + keymap[key] = actCancel + case "forward-char": + keymap[key] = actForwardChar + case "forward-word": + keymap[key] = actForwardWord + case "kill-line": + keymap[key] = actKillLine + case "kill-word": + keymap[key] = actKillWord + case "unix-line-discard", "line-discard": + keymap[key] = actUnixLineDiscard + case "unix-word-rubout", "word-rubout": + keymap[key] = actUnixWordRubout + case "yank": + keymap[key] = actYank + case "backward-kill-word": + keymap[key] = actBackwardKillWord + case "toggle-down": + keymap[key] = actToggleDown + case "toggle-up": + keymap[key] = actToggleUp + case "toggle-all": + keymap[key] = actToggleAll + case "select-all": + keymap[key] = actSelectAll + case "deselect-all": + keymap[key] = actDeselectAll + case "toggle": + keymap[key] = actToggle + case "down": + keymap[key] = actDown + case "up": + keymap[key] = actUp + case "page-up": + keymap[key] = actPageUp + case "page-down": + keymap[key] = actPageDown + case "previous-history": + keymap[key] = actPreviousHistory + case "next-history": + keymap[key] = actNextHistory + case "toggle-sort": + keymap[key] = actToggleSort + toggleSort = true + default: + if isExecuteAction(actLower) { + keymap[key] = actExecute + if act[7] == ':' { + execmap[key] = act[8:] + } else { + execmap[key] = act[8 : len(act)-1] + } + } else { + errorExit("unknown action: " + act) + } + } + } + return keymap, execmap, toggleSort +} + +func isExecuteAction(str string) bool { + if !strings.HasPrefix(str, "execute") || len(str) < 9 { + return false + } + b := str[7] + e := str[len(str)-1] + if b == ':' || b == '(' && e == ')' || b == '[' && e == ']' || + b == e && strings.ContainsAny(string(b), "~!@#$%^&*;/|") { + return true + } + return false +} + +func checkToggleSort(keymap map[int]actionType, str string) map[int]actionType { + keys := parseKeyChords(str, "key name required") + if len(keys) != 1 { + errorExit("multiple keys specified") + } + keymap[firstKey(keys)] = actToggleSort + return keymap +} + +func strLines(str string) []string { + return strings.Split(strings.TrimSuffix(str, "\n"), "\n") +} + +func parseMargin(margin string) [4]string { + margins := strings.Split(margin, ",") + checked := func(str string) string { + if strings.HasSuffix(str, "%") { + val := atof(str[:len(str)-1]) + if val < 0 { + errorExit("margin must be non-negative") + } + if val > 100 { + errorExit("margin too large") + } + } else { + val := atoi(str) + if val < 0 { + errorExit("margin must be non-negative") + } + } + return str + } + switch len(margins) { + case 1: + m := checked(margins[0]) + return [4]string{m, m, m, m} + case 2: + tb := checked(margins[0]) + rl := checked(margins[1]) + return [4]string{tb, rl, tb, rl} + case 3: + t := checked(margins[0]) + rl := checked(margins[1]) + b := checked(margins[2]) + return [4]string{t, rl, b, rl} + case 4: + return [4]string{ + checked(margins[0]), checked(margins[1]), + checked(margins[2]), checked(margins[3])} + default: + errorExit("invalid margin: " + margin) + } + return defaultMargin() +} + +func parseOptions(opts *Options, allArgs []string) { + keymap := make(map[int]actionType) + var historyMax int + if opts.History == nil { + historyMax = defaultHistoryMax + } else { + historyMax = opts.History.maxSize + } + setHistory := func(path string) { + h, e := NewHistory(path, historyMax) + if e != nil { + errorExit(e.Error()) + } + opts.History = h + } + setHistoryMax := func(max int) { + historyMax = max + if historyMax < 1 { + errorExit("history max must be a positive integer") + } + if opts.History != nil { + opts.History.maxSize = historyMax + } + } + for i := 0; i < len(allArgs); i++ { + arg := allArgs[i] + switch arg { + case "-h", "--help": + help(exitOk) + case "-x", "--extended": + opts.Mode = ModeExtended + case "-e", "--extended-exact": + opts.Mode = ModeExtendedExact + case "+x", "--no-extended", "+e", "--no-extended-exact": + opts.Mode = ModeFuzzy + case "-q", "--query": + opts.Query = nextString(allArgs, &i, "query string required") + case "-f", "--filter": + filter := nextString(allArgs, &i, "query string required") + opts.Filter = &filter + case "--expect": + opts.Expect = parseKeyChords(nextString(allArgs, &i, "key names required"), "key names required") + case "--tiebreak": + opts.Tiebreak = parseTiebreak(nextString(allArgs, &i, "sort criterion required")) + case "--bind": + keymap, opts.Execmap, opts.ToggleSort = + parseKeymap(keymap, opts.Execmap, opts.ToggleSort, nextString(allArgs, &i, "bind expression required")) + case "--color": + spec := optionalNextString(allArgs, &i) + if len(spec) == 0 { + opts.Theme = defaultTheme() + } else { + opts.Theme = parseTheme(opts.Theme, spec) + } + case "--toggle-sort": + keymap = checkToggleSort(keymap, nextString(allArgs, &i, "key name required")) + opts.ToggleSort = true + case "-d", "--delimiter": + opts.Delimiter = delimiterRegexp(nextString(allArgs, &i, "delimiter required")) + case "-n", "--nth": + opts.Nth = splitNth(nextString(allArgs, &i, "nth expression required")) + case "--with-nth": + opts.WithNth = splitNth(nextString(allArgs, &i, "nth expression required")) + case "-s", "--sort": + opts.Sort = optionalNumeric(allArgs, &i) + case "+s", "--no-sort": + opts.Sort = 0 + case "--tac": + opts.Tac = true + case "--no-tac": + opts.Tac = false + case "-i": + opts.Case = CaseIgnore + case "+i": + opts.Case = CaseRespect + case "-m", "--multi": + opts.Multi = true + case "+m", "--no-multi": + opts.Multi = false + case "--ansi": + opts.Ansi = true + case "--no-ansi": + opts.Ansi = false + case "--no-mouse": + opts.Mouse = false + case "+c", "--no-color": + opts.Theme = nil + case "+2", "--no-256": + opts.Theme = curses.Default16 + case "--black": + opts.Black = true + case "--no-black": + opts.Black = false + case "--reverse": + opts.Reverse = true + case "--no-reverse": + opts.Reverse = false + case "--cycle": + opts.Cycle = true + case "--no-cycle": + opts.Cycle = false + case "--hscroll": + opts.Hscroll = true + case "--no-hscroll": + opts.Hscroll = false + case "--inline-info": + opts.InlineInfo = true + case "--no-inline-info": + opts.InlineInfo = false + case "-1", "--select-1": + opts.Select1 = true + case "+1", "--no-select-1": + opts.Select1 = false + case "-0", "--exit-0": + opts.Exit0 = true + case "+0", "--no-exit-0": + opts.Exit0 = false + case "--read0": + opts.ReadZero = true + case "--no-read0": + opts.ReadZero = false + case "--print-query": + opts.PrintQuery = true + case "--no-print-query": + opts.PrintQuery = false + case "--prompt": + opts.Prompt = nextString(allArgs, &i, "prompt string required") + case "--sync": + opts.Sync = true + case "--no-sync": + opts.Sync = false + case "--async": + opts.Sync = false + case "--no-history": + opts.History = nil + case "--history": + setHistory(nextString(allArgs, &i, "history file path required")) + case "--history-size": + setHistoryMax(nextInt(allArgs, &i, "history max size required")) + case "--no-header": + opts.Header = []string{} + case "--no-header-lines": + opts.HeaderLines = 0 + case "--header": + opts.Header = strLines(nextString(allArgs, &i, "header string required")) + case "--header-lines": + opts.HeaderLines = atoi( + nextString(allArgs, &i, "number of header lines required")) + case "--no-margin": + opts.Margin = defaultMargin() + case "--margin": + opts.Margin = parseMargin( + nextString(allArgs, &i, "margin required (TRBL / TB,RL / T,RL,B / T,R,B,L)")) + case "--version": + opts.Version = true + default: + if match, value := optString(arg, "-q", "--query="); match { + opts.Query = value + } else if match, value := optString(arg, "-f", "--filter="); match { + opts.Filter = &value + } else if match, value := optString(arg, "-d", "--delimiter="); match { + opts.Delimiter = delimiterRegexp(value) + } else if match, value := optString(arg, "--prompt="); match { + opts.Prompt = value + } else if match, value := optString(arg, "-n", "--nth="); match { + opts.Nth = splitNth(value) + } else if match, value := optString(arg, "--with-nth="); match { + opts.WithNth = splitNth(value) + } else if match, _ := optString(arg, "-s", "--sort="); match { + opts.Sort = 1 // Don't care + } else if match, value := optString(arg, "--toggle-sort="); match { + keymap = checkToggleSort(keymap, value) + opts.ToggleSort = true + } else if match, value := optString(arg, "--expect="); match { + opts.Expect = parseKeyChords(value, "key names required") + } else if match, value := optString(arg, "--tiebreak="); match { + opts.Tiebreak = parseTiebreak(value) + } else if match, value := optString(arg, "--color="); match { + opts.Theme = parseTheme(opts.Theme, value) + } else if match, value := optString(arg, "--bind="); match { + keymap, opts.Execmap, opts.ToggleSort = + parseKeymap(keymap, opts.Execmap, opts.ToggleSort, value) + } else if match, value := optString(arg, "--history="); match { + setHistory(value) + } else if match, value := optString(arg, "--history-size="); match { + setHistoryMax(atoi(value)) + } else if match, value := optString(arg, "--header="); match { + opts.Header = strLines(value) + } else if match, value := optString(arg, "--header-lines="); match { + opts.HeaderLines = atoi(value) + } else if match, value := optString(arg, "--margin="); match { + opts.Margin = parseMargin(value) + } else { + errorExit("unknown option: " + arg) + } + } + } + + if opts.HeaderLines < 0 { + errorExit("header lines must be a non-negative integer") + } + + // Change default actions for CTRL-N / CTRL-P when --history is used + if opts.History != nil { + if _, prs := keymap[curses.CtrlP]; !prs { + keymap[curses.CtrlP] = actPreviousHistory + } + if _, prs := keymap[curses.CtrlN]; !prs { + keymap[curses.CtrlN] = actNextHistory + } + } + + // Override default key bindings + for key, act := range keymap { + opts.Keymap[key] = act + } + + // If we're not using extended search mode, --nth option becomes irrelevant + // if it contains the whole range + if opts.Mode == ModeFuzzy || len(opts.Nth) == 1 { + for _, r := range opts.Nth { + if r.begin == rangeEllipsis && r.end == rangeEllipsis { + opts.Nth = make([]Range, 0) + return + } + } + } +} + +// ParseOptions parses command-line options +func ParseOptions() *Options { + opts := defaultOptions() + + // Options from Env var + words, _ := shellwords.Parse(os.Getenv("FZF_DEFAULT_OPTS")) + parseOptions(opts, words) + + // Options from command-line arguments + parseOptions(opts, os.Args[1:]) + return opts +} diff --git a/src/options_test.go b/src/options_test.go new file mode 100644 index 0000000..1f96f78 --- /dev/null +++ b/src/options_test.go @@ -0,0 +1,318 @@ +package fzf + +import ( + "fmt" + "testing" + + "github.com/junegunn/fzf/src/curses" +) + +func TestDelimiterRegex(t *testing.T) { + // Valid regex + delim := delimiterRegexp(".") + if delim.regex == nil || delim.str != nil { + t.Error(delim) + } + // Broken regex -> string + delim = delimiterRegexp("[0-9") + if delim.regex != nil || *delim.str != "[0-9" { + t.Error(delim) + } + // Valid regex + delim = delimiterRegexp("[0-9]") + if delim.regex.String() != "[0-9]" || delim.str != nil { + t.Error(delim) + } + // Tab character + delim = delimiterRegexp("\t") + if delim.regex != nil || *delim.str != "\t" { + t.Error(delim) + } + // Tab expression + delim = delimiterRegexp("\\t") + if delim.regex != nil || *delim.str != "\t" { + t.Error(delim) + } + // Tabs -> regex + delim = delimiterRegexp("\t+") + if delim.regex == nil || delim.str != nil { + t.Error(delim) + } +} + +func TestDelimiterRegexString(t *testing.T) { + delim := delimiterRegexp("*") + tokens := Tokenize([]rune("-*--*---**---"), delim) + if delim.regex != nil || + string(tokens[0].text) != "-*" || + string(tokens[1].text) != "--*" || + string(tokens[2].text) != "---*" || + string(tokens[3].text) != "*" || + string(tokens[4].text) != "---" { + t.Errorf("%s %s %d", delim, tokens, len(tokens)) + } +} + +func TestDelimiterRegexRegex(t *testing.T) { + delim := delimiterRegexp("--\\*") + tokens := Tokenize([]rune("-*--*---**---"), delim) + if delim.str != nil || + string(tokens[0].text) != "-*--*" || + string(tokens[1].text) != "---*" || + string(tokens[2].text) != "*---" { + t.Errorf("%s %d", tokens, len(tokens)) + } +} + +func TestSplitNth(t *testing.T) { + { + ranges := splitNth("..") + if len(ranges) != 1 || + ranges[0].begin != rangeEllipsis || + ranges[0].end != rangeEllipsis { + t.Errorf("%s", ranges) + } + } + { + ranges := splitNth("..3,1..,2..3,4..-1,-3..-2,..,2,-2,2..-2,1..-1") + if len(ranges) != 10 || + ranges[0].begin != rangeEllipsis || ranges[0].end != 3 || + ranges[1].begin != rangeEllipsis || ranges[1].end != rangeEllipsis || + ranges[2].begin != 2 || ranges[2].end != 3 || + ranges[3].begin != 4 || ranges[3].end != rangeEllipsis || + ranges[4].begin != -3 || ranges[4].end != -2 || + ranges[5].begin != rangeEllipsis || ranges[5].end != rangeEllipsis || + ranges[6].begin != 2 || ranges[6].end != 2 || + ranges[7].begin != -2 || ranges[7].end != -2 || + ranges[8].begin != 2 || ranges[8].end != -2 || + ranges[9].begin != rangeEllipsis || ranges[9].end != rangeEllipsis { + t.Errorf("%s", ranges) + } + } +} + +func TestIrrelevantNth(t *testing.T) { + { + opts := defaultOptions() + words := []string{"--nth", "..", "-x"} + parseOptions(opts, words) + if len(opts.Nth) != 0 { + t.Errorf("nth should be empty: %s", opts.Nth) + } + } + for _, words := range [][]string{[]string{"--nth", "..,3"}, []string{"--nth", "3,1.."}, []string{"--nth", "..-1,1"}} { + { + opts := defaultOptions() + parseOptions(opts, words) + if len(opts.Nth) != 0 { + t.Errorf("nth should be empty: %s", opts.Nth) + } + } + { + opts := defaultOptions() + words = append(words, "-x") + parseOptions(opts, words) + if len(opts.Nth) != 2 { + t.Errorf("nth should not be empty: %s", opts.Nth) + } + } + } +} + +func TestParseKeys(t *testing.T) { + pairs := parseKeyChords("ctrl-z,alt-z,f2,@,Alt-a,!,ctrl-G,J,g", "") + check := func(i int, s string) { + if pairs[i] != s { + t.Errorf("%s != %s", pairs[i], s) + } + } + if len(pairs) != 9 { + t.Error(9) + } + check(curses.CtrlZ, "ctrl-z") + check(curses.AltZ, "alt-z") + check(curses.F2, "f2") + check(curses.AltZ+'@', "@") + check(curses.AltA, "Alt-a") + check(curses.AltZ+'!', "!") + check(curses.CtrlA+'g'-'a', "ctrl-G") + check(curses.AltZ+'J', "J") + check(curses.AltZ+'g', "g") + + // Synonyms + pairs = parseKeyChords("enter,Return,space,tab,btab,esc,up,down,left,right", "") + if len(pairs) != 9 { + t.Error(9) + } + check(curses.CtrlM, "Return") + check(curses.AltZ+' ', "space") + check(curses.Tab, "tab") + check(curses.BTab, "btab") + check(curses.ESC, "esc") + check(curses.Up, "up") + check(curses.Down, "down") + check(curses.Left, "left") + check(curses.Right, "right") + + pairs = parseKeyChords("Tab,Ctrl-I,PgUp,page-up,pgdn,Page-Down,Home,End,Alt-BS,Alt-BSpace,shift-left,shift-right,btab,shift-tab,return,Enter,bspace", "") + if len(pairs) != 11 { + t.Error(11) + } + check(curses.Tab, "Ctrl-I") + check(curses.PgUp, "page-up") + check(curses.PgDn, "Page-Down") + check(curses.Home, "Home") + check(curses.End, "End") + check(curses.AltBS, "Alt-BSpace") + check(curses.SLeft, "shift-left") + check(curses.SRight, "shift-right") + check(curses.BTab, "shift-tab") + check(curses.CtrlM, "Enter") + check(curses.BSpace, "bspace") +} + +func TestParseKeysWithComma(t *testing.T) { + checkN := func(a int, b int) { + if a != b { + t.Errorf("%d != %d", a, b) + } + } + check := func(pairs map[int]string, i int, s string) { + if pairs[i] != s { + t.Errorf("%s != %s", pairs[i], s) + } + } + + pairs := parseKeyChords(",", "") + checkN(len(pairs), 1) + check(pairs, curses.AltZ+',', ",") + + pairs = parseKeyChords(",,a,b", "") + checkN(len(pairs), 3) + check(pairs, curses.AltZ+'a', "a") + check(pairs, curses.AltZ+'b', "b") + check(pairs, curses.AltZ+',', ",") + + pairs = parseKeyChords("a,b,,", "") + checkN(len(pairs), 3) + check(pairs, curses.AltZ+'a', "a") + check(pairs, curses.AltZ+'b', "b") + check(pairs, curses.AltZ+',', ",") + + pairs = parseKeyChords("a,,,b", "") + checkN(len(pairs), 3) + check(pairs, curses.AltZ+'a', "a") + check(pairs, curses.AltZ+'b', "b") + check(pairs, curses.AltZ+',', ",") + + pairs = parseKeyChords("a,,,b,c", "") + checkN(len(pairs), 4) + check(pairs, curses.AltZ+'a', "a") + check(pairs, curses.AltZ+'b', "b") + check(pairs, curses.AltZ+'c', "c") + check(pairs, curses.AltZ+',', ",") + + pairs = parseKeyChords(",,,", "") + checkN(len(pairs), 1) + check(pairs, curses.AltZ+',', ",") +} + +func TestBind(t *testing.T) { + check := func(action actionType, expected actionType) { + if action != expected { + t.Errorf("%d != %d", action, expected) + } + } + checkString := func(action string, expected string) { + if action != expected { + t.Errorf("%d != %d", action, expected) + } + } + keymap := defaultKeymap() + execmap := make(map[int]string) + check(actBeginningOfLine, keymap[curses.CtrlA]) + keymap, execmap, toggleSort := + parseKeymap(keymap, execmap, false, + "ctrl-a:kill-line,ctrl-b:toggle-sort,c:page-up,alt-z:page-down,"+ + "f1:execute(ls {}),f2:execute/echo {}, {}, {}/,f3:execute[echo '({})'],f4:execute;less {};,"+ + "alt-a:execute@echo (,),[,],/,:,;,%,{}@,alt-b:execute;echo (,),[,],/,:,@,%,{};"+ + ",,:abort,::accept,X:execute:\nfoobar,Y:execute(baz)") + if !toggleSort { + t.Errorf("toggleSort not set") + } + check(actKillLine, keymap[curses.CtrlA]) + check(actToggleSort, keymap[curses.CtrlB]) + check(actPageUp, keymap[curses.AltZ+'c']) + check(actAbort, keymap[curses.AltZ+',']) + check(actAccept, keymap[curses.AltZ+':']) + check(actPageDown, keymap[curses.AltZ]) + check(actExecute, keymap[curses.F1]) + check(actExecute, keymap[curses.F2]) + check(actExecute, keymap[curses.F3]) + check(actExecute, keymap[curses.F4]) + checkString("ls {}", execmap[curses.F1]) + checkString("echo {}, {}, {}", execmap[curses.F2]) + checkString("echo '({})'", execmap[curses.F3]) + checkString("less {}", execmap[curses.F4]) + checkString("echo (,),[,],/,:,;,%,{}", execmap[curses.AltA]) + checkString("echo (,),[,],/,:,@,%,{}", execmap[curses.AltB]) + checkString("\nfoobar,Y:execute(baz)", execmap[curses.AltZ+'X']) + + for idx, char := range []rune{'~', '!', '@', '#', '$', '%', '^', '&', '*', '|', ';', '/'} { + keymap, execmap, toggleSort = + parseKeymap(keymap, execmap, false, fmt.Sprintf("%d:execute%cfoobar%c", idx%10, char, char)) + checkString("foobar", execmap[curses.AltZ+int([]rune(fmt.Sprintf("%d", idx%10))[0])]) + } + + keymap, execmap, toggleSort = parseKeymap(keymap, execmap, false, "f1:abort") + if toggleSort { + t.Errorf("toggleSort set") + } + check(actAbort, keymap[curses.F1]) +} + +func TestColorSpec(t *testing.T) { + theme := curses.Dark256 + dark := parseTheme(theme, "dark") + if *dark != *theme { + t.Errorf("colors should be equivalent") + } + if dark == theme { + t.Errorf("point should not be equivalent") + } + + light := parseTheme(theme, "dark,light") + if *light == *theme { + t.Errorf("should not be equivalent") + } + if *light != *curses.Light256 { + t.Errorf("colors should be equivalent") + } + if light == theme { + t.Errorf("point should not be equivalent") + } + + customized := parseTheme(theme, "fg:231,bg:232") + if customized.Fg != 231 || customized.Bg != 232 { + t.Errorf("color not customized") + } + if *curses.Dark256 == *customized { + t.Errorf("colors should not be equivalent") + } + customized.Fg = curses.Dark256.Fg + customized.Bg = curses.Dark256.Bg + if *curses.Dark256 == *customized { + t.Errorf("colors should now be equivalent") + } + + customized = parseTheme(theme, "fg:231,dark,bg:232") + if customized.Fg != curses.Dark256.Fg || customized.Bg == curses.Dark256.Bg { + t.Errorf("color not customized") + } + if customized.UseDefault { + t.Errorf("not using default colors") + } + if !curses.Dark256.UseDefault { + t.Errorf("using default colors") + } +} diff --git a/src/pattern.go b/src/pattern.go new file mode 100644 index 0000000..5466b86 --- /dev/null +++ b/src/pattern.go @@ -0,0 +1,338 @@ +package fzf + +import ( + "regexp" + "sort" + "strings" + + "github.com/junegunn/fzf/src/algo" +) + +// fuzzy +// 'exact +// ^exact-prefix +// exact-suffix$ +// !not-fuzzy +// !'not-exact +// !^not-exact-prefix +// !not-exact-suffix$ + +type termType int + +const ( + termFuzzy termType = iota + termExact + termPrefix + termSuffix + termEqual +) + +type term struct { + typ termType + inv bool + text []rune + caseSensitive bool + origText []rune +} + +// Pattern represents search pattern +type Pattern struct { + mode Mode + caseSensitive bool + forward bool + text []rune + terms []term + hasInvTerm bool + delimiter Delimiter + nth []Range + procFun map[termType]func(bool, bool, []rune, []rune) (int, int) +} + +var ( + _patternCache map[string]*Pattern + _splitRegex *regexp.Regexp + _cache ChunkCache +) + +func init() { + _splitRegex = regexp.MustCompile("\\s+") + clearPatternCache() + clearChunkCache() +} + +func clearPatternCache() { + // We can uniquely identify the pattern for a given string since + // mode and caseMode do not change while the program is running + _patternCache = make(map[string]*Pattern) +} + +func clearChunkCache() { + _cache = NewChunkCache() +} + +// BuildPattern builds Pattern object from the given arguments +func BuildPattern(mode Mode, caseMode Case, forward bool, + nth []Range, delimiter Delimiter, runes []rune) *Pattern { + + var asString string + switch mode { + case ModeExtended, ModeExtendedExact: + asString = strings.Trim(string(runes), " ") + default: + asString = string(runes) + } + + cached, found := _patternCache[asString] + if found { + return cached + } + + caseSensitive, hasInvTerm := true, false + terms := []term{} + + switch mode { + case ModeExtended, ModeExtendedExact: + terms = parseTerms(mode, caseMode, asString) + for _, term := range terms { + if term.inv { + hasInvTerm = true + } + } + default: + lowerString := strings.ToLower(asString) + caseSensitive = caseMode == CaseRespect || + caseMode == CaseSmart && lowerString != asString + if !caseSensitive { + asString = lowerString + } + } + + ptr := &Pattern{ + mode: mode, + caseSensitive: caseSensitive, + forward: forward, + text: []rune(asString), + terms: terms, + hasInvTerm: hasInvTerm, + nth: nth, + delimiter: delimiter, + procFun: make(map[termType]func(bool, bool, []rune, []rune) (int, int))} + + ptr.procFun[termFuzzy] = algo.FuzzyMatch + ptr.procFun[termEqual] = algo.EqualMatch + ptr.procFun[termExact] = algo.ExactMatchNaive + ptr.procFun[termPrefix] = algo.PrefixMatch + ptr.procFun[termSuffix] = algo.SuffixMatch + + _patternCache[asString] = ptr + return ptr +} + +func parseTerms(mode Mode, caseMode Case, str string) []term { + tokens := _splitRegex.Split(str, -1) + terms := []term{} + for _, token := range tokens { + typ, inv, text := termFuzzy, false, token + lowerText := strings.ToLower(text) + caseSensitive := caseMode == CaseRespect || + caseMode == CaseSmart && text != lowerText + if !caseSensitive { + text = lowerText + } + origText := []rune(text) + if mode == ModeExtendedExact { + typ = termExact + } + + if strings.HasPrefix(text, "!") { + inv = true + text = text[1:] + } + + if strings.HasPrefix(text, "'") { + if mode == ModeExtended { + typ = termExact + text = text[1:] + } else if mode == ModeExtendedExact { + typ = termFuzzy + text = text[1:] + } + } else if strings.HasPrefix(text, "^") { + if strings.HasSuffix(text, "$") { + typ = termEqual + text = text[1 : len(text)-1] + } else { + typ = termPrefix + text = text[1:] + } + } else if strings.HasSuffix(text, "$") { + typ = termSuffix + text = text[:len(text)-1] + } + + if len(text) > 0 { + terms = append(terms, term{ + typ: typ, + inv: inv, + text: []rune(text), + caseSensitive: caseSensitive, + origText: origText}) + } + } + return terms +} + +// IsEmpty returns true if the pattern is effectively empty +func (p *Pattern) IsEmpty() bool { + if p.mode == ModeFuzzy { + return len(p.text) == 0 + } + return len(p.terms) == 0 +} + +// AsString returns the search query in string type +func (p *Pattern) AsString() string { + return string(p.text) +} + +// CacheKey is used to build string to be used as the key of result cache +func (p *Pattern) CacheKey() string { + if p.mode == ModeFuzzy { + return p.AsString() + } + cacheableTerms := []string{} + for _, term := range p.terms { + if term.inv { + continue + } + cacheableTerms = append(cacheableTerms, string(term.origText)) + } + return strings.Join(cacheableTerms, " ") +} + +// Match returns the list of matches Items in the given Chunk +func (p *Pattern) Match(chunk *Chunk) []*Item { + space := chunk + + // ChunkCache: Exact match + cacheKey := p.CacheKey() + if !p.hasInvTerm { // Because we're excluding Inv-term from cache key + if cached, found := _cache.Find(chunk, cacheKey); found { + return cached + } + } + + // ChunkCache: Prefix/suffix match +Loop: + for idx := 1; idx < len(cacheKey); idx++ { + // [---------| ] | [ |---------] + // [--------| ] | [ |--------] + // [-------| ] | [ |-------] + prefix := cacheKey[:len(cacheKey)-idx] + suffix := cacheKey[idx:] + for _, substr := range [2]*string{&prefix, &suffix} { + if cached, found := _cache.Find(chunk, *substr); found { + cachedChunk := Chunk(cached) + space = &cachedChunk + break Loop + } + } + } + + matches := p.matchChunk(space) + + if !p.hasInvTerm { + _cache.Add(chunk, cacheKey, matches) + } + return matches +} + +func (p *Pattern) matchChunk(chunk *Chunk) []*Item { + matches := []*Item{} + if p.mode == ModeFuzzy { + for _, item := range *chunk { + if sidx, eidx := p.fuzzyMatch(item); sidx >= 0 { + matches = append(matches, + dupItem(item, []Offset{Offset{int32(sidx), int32(eidx)}})) + } + } + } else { + for _, item := range *chunk { + if offsets := p.extendedMatch(item); len(offsets) == len(p.terms) { + matches = append(matches, dupItem(item, offsets)) + } + } + } + return matches +} + +// MatchItem returns true if the Item is a match +func (p *Pattern) MatchItem(item *Item) bool { + if p.mode == ModeFuzzy { + sidx, _ := p.fuzzyMatch(item) + return sidx >= 0 + } + offsets := p.extendedMatch(item) + return len(offsets) == len(p.terms) +} + +func dupItem(item *Item, offsets []Offset) *Item { + sort.Sort(ByOrder(offsets)) + return &Item{ + text: item.text, + origText: item.origText, + transformed: item.transformed, + index: item.index, + offsets: offsets, + colors: item.colors, + rank: Rank{0, 0, item.index}} +} + +func (p *Pattern) fuzzyMatch(item *Item) (int, int) { + input := p.prepareInput(item) + return p.iter(algo.FuzzyMatch, input, p.caseSensitive, p.forward, p.text) +} + +func (p *Pattern) extendedMatch(item *Item) []Offset { + input := p.prepareInput(item) + offsets := []Offset{} + for _, term := range p.terms { + pfun := p.procFun[term.typ] + if sidx, eidx := p.iter(pfun, input, term.caseSensitive, p.forward, term.text); sidx >= 0 { + if term.inv { + break + } + offsets = append(offsets, Offset{int32(sidx), int32(eidx)}) + } else if term.inv { + offsets = append(offsets, Offset{0, 0}) + } + } + return offsets +} + +func (p *Pattern) prepareInput(item *Item) []Token { + if item.transformed != nil { + return item.transformed + } + + var ret []Token + if len(p.nth) > 0 { + tokens := Tokenize(item.text, p.delimiter) + ret = Transform(tokens, p.nth) + } else { + ret = []Token{Token{text: item.text, prefixLength: 0}} + } + item.transformed = ret + return ret +} + +func (p *Pattern) iter(pfun func(bool, bool, []rune, []rune) (int, int), + tokens []Token, caseSensitive bool, forward bool, pattern []rune) (int, int) { + for _, part := range tokens { + prefixLength := part.prefixLength + if sidx, eidx := pfun(caseSensitive, forward, part.text, pattern); sidx >= 0 { + return sidx + prefixLength, eidx + prefixLength + } + } + return -1, -1 +} diff --git a/src/pattern_test.go b/src/pattern_test.go new file mode 100644 index 0000000..d508612 --- /dev/null +++ b/src/pattern_test.go @@ -0,0 +1,132 @@ +package fzf + +import ( + "reflect" + "testing" + + "github.com/junegunn/fzf/src/algo" +) + +func TestParseTermsExtended(t *testing.T) { + terms := parseTerms(ModeExtended, CaseSmart, + "aaa 'bbb ^ccc ddd$ !eee !'fff !^ggg !hhh$ ^iii$") + if len(terms) != 9 || + terms[0].typ != termFuzzy || terms[0].inv || + terms[1].typ != termExact || terms[1].inv || + terms[2].typ != termPrefix || terms[2].inv || + terms[3].typ != termSuffix || terms[3].inv || + terms[4].typ != termFuzzy || !terms[4].inv || + terms[5].typ != termExact || !terms[5].inv || + terms[6].typ != termPrefix || !terms[6].inv || + terms[7].typ != termSuffix || !terms[7].inv || + terms[8].typ != termEqual || terms[8].inv { + t.Errorf("%s", terms) + } + for idx, term := range terms { + if len(term.text) != 3 { + t.Errorf("%s", term) + } + if idx > 0 && len(term.origText) != 4+idx/5 { + t.Errorf("%s", term) + } + } +} + +func TestParseTermsExtendedExact(t *testing.T) { + terms := parseTerms(ModeExtendedExact, CaseSmart, + "aaa 'bbb ^ccc ddd$ !eee !'fff !^ggg !hhh$") + if len(terms) != 8 || + terms[0].typ != termExact || terms[0].inv || len(terms[0].text) != 3 || + terms[1].typ != termFuzzy || terms[1].inv || len(terms[1].text) != 3 || + terms[2].typ != termPrefix || terms[2].inv || len(terms[2].text) != 3 || + terms[3].typ != termSuffix || terms[3].inv || len(terms[3].text) != 3 || + terms[4].typ != termExact || !terms[4].inv || len(terms[4].text) != 3 || + terms[5].typ != termFuzzy || !terms[5].inv || len(terms[5].text) != 3 || + terms[6].typ != termPrefix || !terms[6].inv || len(terms[6].text) != 3 || + terms[7].typ != termSuffix || !terms[7].inv || len(terms[7].text) != 3 { + t.Errorf("%s", terms) + } +} + +func TestParseTermsEmpty(t *testing.T) { + terms := parseTerms(ModeExtended, CaseSmart, "' $ ^ !' !^ !$") + if len(terms) != 0 { + t.Errorf("%s", terms) + } +} + +func TestExact(t *testing.T) { + defer clearPatternCache() + clearPatternCache() + pattern := BuildPattern(ModeExtended, CaseSmart, true, + []Range{}, Delimiter{}, []rune("'abc")) + sidx, eidx := algo.ExactMatchNaive( + pattern.caseSensitive, pattern.forward, []rune("aabbcc abc"), pattern.terms[0].text) + if sidx != 7 || eidx != 10 { + t.Errorf("%s / %d / %d", pattern.terms, sidx, eidx) + } +} + +func TestEqual(t *testing.T) { + defer clearPatternCache() + clearPatternCache() + pattern := BuildPattern(ModeExtended, CaseSmart, true, []Range{}, Delimiter{}, []rune("^AbC$")) + + match := func(str string, sidxExpected int, eidxExpected int) { + sidx, eidx := algo.EqualMatch( + pattern.caseSensitive, pattern.forward, []rune(str), pattern.terms[0].text) + if sidx != sidxExpected || eidx != eidxExpected { + t.Errorf("%s / %d / %d", pattern.terms, sidx, eidx) + } + } + match("ABC", -1, -1) + match("AbC", 0, 3) +} + +func TestCaseSensitivity(t *testing.T) { + defer clearPatternCache() + clearPatternCache() + pat1 := BuildPattern(ModeFuzzy, CaseSmart, true, []Range{}, Delimiter{}, []rune("abc")) + clearPatternCache() + pat2 := BuildPattern(ModeFuzzy, CaseSmart, true, []Range{}, Delimiter{}, []rune("Abc")) + clearPatternCache() + pat3 := BuildPattern(ModeFuzzy, CaseIgnore, true, []Range{}, Delimiter{}, []rune("abc")) + clearPatternCache() + pat4 := BuildPattern(ModeFuzzy, CaseIgnore, true, []Range{}, Delimiter{}, []rune("Abc")) + clearPatternCache() + pat5 := BuildPattern(ModeFuzzy, CaseRespect, true, []Range{}, Delimiter{}, []rune("abc")) + clearPatternCache() + pat6 := BuildPattern(ModeFuzzy, CaseRespect, true, []Range{}, Delimiter{}, []rune("Abc")) + + if string(pat1.text) != "abc" || pat1.caseSensitive != false || + string(pat2.text) != "Abc" || pat2.caseSensitive != true || + string(pat3.text) != "abc" || pat3.caseSensitive != false || + string(pat4.text) != "abc" || pat4.caseSensitive != false || + string(pat5.text) != "abc" || pat5.caseSensitive != true || + string(pat6.text) != "Abc" || pat6.caseSensitive != true { + t.Error("Invalid case conversion") + } +} + +func TestOrigTextAndTransformed(t *testing.T) { + pattern := BuildPattern(ModeExtended, CaseSmart, true, []Range{}, Delimiter{}, []rune("jg")) + tokens := Tokenize([]rune("junegunn"), Delimiter{}) + trans := Transform(tokens, []Range{Range{1, 1}}) + + origRunes := []rune("junegunn.choi") + for _, mode := range []Mode{ModeFuzzy, ModeExtended} { + chunk := Chunk{ + &Item{ + text: []rune("junegunn"), + origText: &origRunes, + transformed: trans}, + } + pattern.mode = mode + matches := pattern.matchChunk(&chunk) + if string(matches[0].text) != "junegunn" || string(*matches[0].origText) != "junegunn.choi" || + matches[0].offsets[0][0] != 0 || matches[0].offsets[0][1] != 5 || + !reflect.DeepEqual(matches[0].transformed, trans) { + t.Error("Invalid match result", matches) + } + } +} diff --git a/src/reader.go b/src/reader.go new file mode 100644 index 0000000..3e2cf0a --- /dev/null +++ b/src/reader.go @@ -0,0 +1,73 @@ +package fzf + +import ( + "bufio" + "io" + "os" + "os/exec" + + "github.com/junegunn/fzf/src/util" +) + +// Reader reads from command or standard input +type Reader struct { + pusher func([]byte) bool + eventBox *util.EventBox + delimNil bool +} + +// ReadSource reads data from the default command or from standard input +func (r *Reader) ReadSource() { + if util.IsTty() { + cmd := os.Getenv("FZF_DEFAULT_COMMAND") + if len(cmd) == 0 { + cmd = defaultCommand + } + r.readFromCommand(cmd) + } else { + r.readFromStdin() + } + r.eventBox.Set(EvtReadFin, nil) +} + +func (r *Reader) feed(src io.Reader) { + delim := byte('\n') + if r.delimNil { + delim = '\000' + } + reader := bufio.NewReader(src) + for { + // ReadBytes returns err != nil if and only if the returned data does not + // end in delim. + bytea, err := reader.ReadBytes(delim) + if len(bytea) > 0 { + if err == nil { + bytea = bytea[:len(bytea)-1] + } + if r.pusher(bytea) { + r.eventBox.Set(EvtReadNew, nil) + } + } + if err != nil { + break + } + } +} + +func (r *Reader) readFromStdin() { + r.feed(os.Stdin) +} + +func (r *Reader) readFromCommand(cmd string) { + listCommand := exec.Command("sh", "-c", cmd) + out, err := listCommand.StdoutPipe() + if err != nil { + return + } + err = listCommand.Start() + if err != nil { + return + } + defer listCommand.Wait() + r.feed(out) +} diff --git a/src/reader_test.go b/src/reader_test.go new file mode 100644 index 0000000..d5c218c --- /dev/null +++ b/src/reader_test.go @@ -0,0 +1,56 @@ +package fzf + +import ( + "testing" + + "github.com/junegunn/fzf/src/util" +) + +func TestReadFromCommand(t *testing.T) { + strs := []string{} + eb := util.NewEventBox() + reader := Reader{ + pusher: func(s []byte) bool { strs = append(strs, string(s)); return true }, + eventBox: eb} + + // Check EventBox + if eb.Peek(EvtReadNew) { + t.Error("EvtReadNew should not be set yet") + } + + // Normal command + reader.readFromCommand(`echo abc && echo def`) + if len(strs) != 2 || strs[0] != "abc" || strs[1] != "def" { + t.Errorf("%s", strs) + } + + // Check EventBox again + if !eb.Peek(EvtReadNew) { + t.Error("EvtReadNew should be set yet") + } + + // Wait should return immediately + eb.Wait(func(events *util.Events) { + if _, found := (*events)[EvtReadNew]; !found { + t.Errorf("%s", events) + } + events.Clear() + }) + + // EventBox is cleared + if eb.Peek(EvtReadNew) { + t.Error("EvtReadNew should not be set yet") + } + + // Failing command + reader.readFromCommand(`no-such-command`) + strs = []string{} + if len(strs) > 0 { + t.Errorf("%s", strs) + } + + // Check EventBox again + if eb.Peek(EvtReadNew) { + t.Error("Command failed. EvtReadNew should be set") + } +} diff --git a/src/terminal.go b/src/terminal.go new file mode 100644 index 0000000..9a92ba4 --- /dev/null +++ b/src/terminal.go @@ -0,0 +1,1119 @@ +package fzf + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "os/signal" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + C "github.com/junegunn/fzf/src/curses" + "github.com/junegunn/fzf/src/util" + + "github.com/junegunn/go-runewidth" +) + +// Terminal represents terminal input/output +type Terminal struct { + inlineInfo bool + prompt string + reverse bool + hscroll bool + cx int + cy int + offset int + yanked []rune + input []rune + multi bool + sort bool + toggleSort bool + expect map[int]string + keymap map[int]actionType + execmap map[int]string + pressed string + printQuery bool + history *History + cycle bool + header []string + header0 []string + ansi bool + margin [4]string + marginInt [4]int + count int + progress int + reading bool + merger *Merger + selected map[uint32]selectedItem + reqBox *util.EventBox + eventBox *util.EventBox + mutex sync.Mutex + initFunc func() + suppress bool + startChan chan bool +} + +type selectedItem struct { + at time.Time + text *string +} + +type byTimeOrder []selectedItem + +func (a byTimeOrder) Len() int { + return len(a) +} + +func (a byTimeOrder) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a byTimeOrder) Less(i, j int) bool { + return a[i].at.Before(a[j].at) +} + +var _spinner = []string{`-`, `\`, `|`, `/`, `-`, `\`, `|`, `/`} +var _runeWidths = make(map[rune]int) + +const ( + reqPrompt util.EventType = iota + reqInfo + reqHeader + reqList + reqRefresh + reqRedraw + reqClose + reqQuit +) + +type actionType int + +const ( + actIgnore actionType = iota + actInvalid + actRune + actMouse + actBeginningOfLine + actAbort + actAccept + actBackwardChar + actBackwardDeleteChar + actBackwardWord + actCancel + actClearScreen + actDeleteChar + actDeleteCharEOF + actEndOfLine + actForwardChar + actForwardWord + actKillLine + actKillWord + actUnixLineDiscard + actUnixWordRubout + actYank + actBackwardKillWord + actSelectAll + actDeselectAll + actToggle + actToggleAll + actToggleDown + actToggleUp + actDown + actUp + actPageUp + actPageDown + actToggleSort + actPreviousHistory + actNextHistory + actExecute +) + +func defaultKeymap() map[int]actionType { + keymap := make(map[int]actionType) + keymap[C.Invalid] = actInvalid + keymap[C.CtrlA] = actBeginningOfLine + keymap[C.CtrlB] = actBackwardChar + keymap[C.CtrlC] = actAbort + keymap[C.CtrlG] = actAbort + keymap[C.CtrlQ] = actAbort + keymap[C.ESC] = actAbort + keymap[C.CtrlD] = actDeleteCharEOF + keymap[C.CtrlE] = actEndOfLine + keymap[C.CtrlF] = actForwardChar + keymap[C.CtrlH] = actBackwardDeleteChar + keymap[C.BSpace] = actBackwardDeleteChar + keymap[C.Tab] = actToggleDown + keymap[C.BTab] = actToggleUp + keymap[C.CtrlJ] = actDown + keymap[C.CtrlK] = actUp + keymap[C.CtrlL] = actClearScreen + keymap[C.CtrlM] = actAccept + keymap[C.CtrlN] = actDown + keymap[C.CtrlP] = actUp + keymap[C.CtrlU] = actUnixLineDiscard + keymap[C.CtrlW] = actUnixWordRubout + keymap[C.CtrlY] = actYank + + keymap[C.AltB] = actBackwardWord + keymap[C.SLeft] = actBackwardWord + keymap[C.AltF] = actForwardWord + keymap[C.SRight] = actForwardWord + keymap[C.AltD] = actKillWord + keymap[C.AltBS] = actBackwardKillWord + + keymap[C.Up] = actUp + keymap[C.Down] = actDown + keymap[C.Left] = actBackwardChar + keymap[C.Right] = actForwardChar + + keymap[C.Home] = actBeginningOfLine + keymap[C.End] = actEndOfLine + keymap[C.Del] = actDeleteChar + keymap[C.PgUp] = actPageUp + keymap[C.PgDn] = actPageDown + + keymap[C.Rune] = actRune + keymap[C.Mouse] = actMouse + return keymap +} + +// NewTerminal returns new Terminal object +func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { + input := []rune(opts.Query) + var header []string + if opts.Reverse { + header = opts.Header + } else { + header = reverseStringArray(opts.Header) + } + return &Terminal{ + inlineInfo: opts.InlineInfo, + prompt: opts.Prompt, + reverse: opts.Reverse, + hscroll: opts.Hscroll, + cx: len(input), + cy: 0, + offset: 0, + yanked: []rune{}, + input: input, + multi: opts.Multi, + sort: opts.Sort > 0, + toggleSort: opts.ToggleSort, + expect: opts.Expect, + keymap: opts.Keymap, + execmap: opts.Execmap, + pressed: "", + printQuery: opts.PrintQuery, + history: opts.History, + margin: opts.Margin, + marginInt: [4]int{0, 0, 0, 0}, + cycle: opts.Cycle, + header: header, + header0: header, + ansi: opts.Ansi, + reading: true, + merger: EmptyMerger, + selected: make(map[uint32]selectedItem), + reqBox: util.NewEventBox(), + eventBox: eventBox, + mutex: sync.Mutex{}, + suppress: true, + startChan: make(chan bool, 1), + initFunc: func() { + C.Init(opts.Theme, opts.Black, opts.Mouse) + }} +} + +// Input returns current query string +func (t *Terminal) Input() []rune { + t.mutex.Lock() + defer t.mutex.Unlock() + return copySlice(t.input) +} + +// UpdateCount updates the count information +func (t *Terminal) UpdateCount(cnt int, final bool) { + t.mutex.Lock() + t.count = cnt + t.reading = !final + t.mutex.Unlock() + t.reqBox.Set(reqInfo, nil) + if final { + t.reqBox.Set(reqRefresh, nil) + } +} + +func reverseStringArray(input []string) []string { + size := len(input) + reversed := make([]string, size) + for idx, str := range input { + reversed[size-idx-1] = str + } + return reversed +} + +// UpdateHeader updates the header +func (t *Terminal) UpdateHeader(header []string) { + t.mutex.Lock() + t.header = append(append([]string{}, t.header0...), header...) + t.mutex.Unlock() + t.reqBox.Set(reqHeader, nil) +} + +// UpdateProgress updates the search progress +func (t *Terminal) UpdateProgress(progress float32) { + t.mutex.Lock() + newProgress := int(progress * 100) + changed := t.progress != newProgress + t.progress = newProgress + t.mutex.Unlock() + + if changed { + t.reqBox.Set(reqInfo, nil) + } +} + +// UpdateList updates Merger to display the list +func (t *Terminal) UpdateList(merger *Merger) { + t.mutex.Lock() + t.progress = 100 + t.merger = merger + t.mutex.Unlock() + t.reqBox.Set(reqInfo, nil) + t.reqBox.Set(reqList, nil) +} + +func (t *Terminal) output() bool { + if t.printQuery { + fmt.Println(string(t.input)) + } + if len(t.expect) > 0 { + fmt.Println(t.pressed) + } + found := len(t.selected) > 0 + if !found { + cnt := t.merger.Length() + if cnt > 0 && cnt > t.cy { + fmt.Println(t.merger.Get(t.cy).AsString(t.ansi)) + found = true + } + } else { + sels := make([]selectedItem, 0, len(t.selected)) + for _, sel := range t.selected { + sels = append(sels, sel) + } + sort.Sort(byTimeOrder(sels)) + for _, sel := range sels { + fmt.Println(*sel.text) + } + } + return found +} + +func runeWidth(r rune, prefixWidth int) int { + if r == '\t' { + return 8 - prefixWidth%8 + } else if w, found := _runeWidths[r]; found { + return w + } else { + w := runewidth.RuneWidth(r) + _runeWidths[r] = w + return w + } +} + +func displayWidth(runes []rune) int { + l := 0 + for _, r := range runes { + l += runeWidth(r, l) + } + return l +} + +const minWidth = 16 +const minHeight = 4 + +func (t *Terminal) calculateMargins() { + screenWidth := C.MaxX() + screenHeight := C.MaxY() + for idx, str := range t.margin { + if str == "0" { + t.marginInt[idx] = 0 + } else if strings.HasSuffix(str, "%") { + num, _ := strconv.ParseFloat(str[:len(str)-1], 64) + var val float64 + if idx%2 == 0 { + val = float64(screenHeight) + } else { + val = float64(screenWidth) + } + t.marginInt[idx] = int(val * num * 0.01) + } else { + num, _ := strconv.Atoi(str) + t.marginInt[idx] = num + } + } + adjust := func(idx1 int, idx2 int, max int, min int) { + if max >= min { + margin := t.marginInt[idx1] + t.marginInt[idx2] + if max-margin < min { + desired := max - min + t.marginInt[idx1] = desired * t.marginInt[idx1] / margin + t.marginInt[idx2] = desired * t.marginInt[idx2] / margin + } + } + } + adjust(1, 3, screenWidth, minWidth) + adjust(0, 2, screenHeight, minHeight) +} + +func (t *Terminal) move(y int, x int, clear bool) { + x += t.marginInt[3] + maxy := C.MaxY() + if !t.reverse { + y = maxy - y - 1 - t.marginInt[2] + } else { + y += t.marginInt[0] + } + + if clear { + C.MoveAndClear(y, x) + } else { + C.Move(y, x) + } +} + +func (t *Terminal) placeCursor() { + t.move(0, len(t.prompt)+displayWidth(t.input[:t.cx]), false) +} + +func (t *Terminal) printPrompt() { + t.move(0, 0, true) + C.CPrint(C.ColPrompt, true, t.prompt) + C.CPrint(C.ColNormal, true, string(t.input)) +} + +func (t *Terminal) printInfo() { + if t.inlineInfo { + t.move(0, len(t.prompt)+displayWidth(t.input)+1, true) + if t.reading { + C.CPrint(C.ColSpinner, true, " < ") + } else { + C.CPrint(C.ColPrompt, true, " < ") + } + } else { + t.move(1, 0, true) + if t.reading { + duration := int64(spinnerDuration) + idx := (time.Now().UnixNano() % (duration * int64(len(_spinner)))) / duration + C.CPrint(C.ColSpinner, true, _spinner[idx]) + } + t.move(1, 2, false) + } + + output := fmt.Sprintf("%d/%d", t.merger.Length(), t.count) + if t.toggleSort { + if t.sort { + output += "/S" + } else { + output += " " + } + } + if t.multi && len(t.selected) > 0 { + output += fmt.Sprintf(" (%d)", len(t.selected)) + } + if t.progress > 0 && t.progress < 100 { + output += fmt.Sprintf(" (%d%%)", t.progress) + } + C.CPrint(C.ColInfo, false, output) +} + +func (t *Terminal) maxHeight() int { + return C.MaxY() - t.marginInt[0] - t.marginInt[2] +} + +func (t *Terminal) printHeader() { + if len(t.header) == 0 { + return + } + max := t.maxHeight() + var state *ansiState + for idx, lineStr := range t.header { + line := idx + 2 + if t.inlineInfo { + line-- + } + if line >= max { + continue + } + trimmed, colors, newState := extractColor(lineStr, state) + state = newState + item := &Item{ + text: []rune(trimmed), + index: 0, + colors: colors, + rank: Rank{0, 0, 0}} + + t.move(line, 2, true) + t.printHighlighted(item, false, C.ColHeader, 0, false) + } +} + +func (t *Terminal) printList() { + t.constrain() + + maxy := t.maxItems() + count := t.merger.Length() - t.offset + for i := 0; i < maxy; i++ { + line := i + 2 + len(t.header) + if t.inlineInfo { + line-- + } + t.move(line, 0, true) + if i < count { + t.printItem(t.merger.Get(i+t.offset), i == t.cy-t.offset) + } + } +} + +func (t *Terminal) printItem(item *Item, current bool) { + _, selected := t.selected[item.index] + if current { + C.CPrint(C.ColCursor, true, ">") + if selected { + C.CPrint(C.ColSelected, true, ">") + } else { + C.CPrint(C.ColCurrent, true, " ") + } + t.printHighlighted(item, true, C.ColCurrent, C.ColCurrentMatch, true) + } else { + C.CPrint(C.ColCursor, true, " ") + if selected { + C.CPrint(C.ColSelected, true, ">") + } else { + C.Print(" ") + } + t.printHighlighted(item, false, 0, C.ColMatch, false) + } +} + +func trimRight(runes []rune, width int) ([]rune, int) { + // We start from the beginning to handle tab characters + l := 0 + for idx, r := range runes { + l += runeWidth(r, l) + if idx > 0 && l > width { + return runes[:idx], len(runes) - idx + } + } + return runes, 0 +} + +func displayWidthWithLimit(runes []rune, prefixWidth int, limit int) int { + l := 0 + for _, r := range runes { + l += runeWidth(r, l+prefixWidth) + if l > limit { + // Early exit + return l + } + } + return l +} + +func trimLeft(runes []rune, width int) ([]rune, int32) { + currentWidth := displayWidth(runes) + var trimmed int32 + + for currentWidth > width && len(runes) > 0 { + runes = runes[1:] + trimmed++ + currentWidth = displayWidthWithLimit(runes, 2, width) + } + return runes, trimmed +} + +func (t *Terminal) printHighlighted(item *Item, bold bool, col1 int, col2 int, current bool) { + var maxe int32 + for _, offset := range item.offsets { + if offset[1] > maxe { + maxe = offset[1] + } + } + + // Overflow + text := make([]rune, len(item.text)) + copy(text, item.text) + offsets := item.colorOffsets(col2, bold, current) + maxWidth := C.MaxX() - 3 - t.marginInt[1] - t.marginInt[3] + fullWidth := displayWidth(text) + if fullWidth > maxWidth { + if t.hscroll { + // Stri.. + matchEndWidth := displayWidth(text[:maxe]) + if matchEndWidth <= maxWidth-2 { + text, _ = trimRight(text, maxWidth-2) + text = append(text, []rune("..")...) + } else { + // Stri.. + if matchEndWidth < fullWidth-2 { + text = append(text[:maxe], []rune("..")...) + } + // ..ri.. + var diff int32 + text, diff = trimLeft(text, maxWidth-2) + + // Transform offsets + for idx, offset := range offsets { + b, e := offset.offset[0], offset.offset[1] + b += 2 - diff + e += 2 - diff + b = util.Max32(b, 2) + offsets[idx].offset[0] = b + offsets[idx].offset[1] = util.Max32(b, e) + } + text = append([]rune(".."), text...) + } + } else { + text, _ = trimRight(text, maxWidth-2) + text = append(text, []rune("..")...) + + for idx, offset := range offsets { + offsets[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-2)) + offsets[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth)) + } + } + } + + var index int32 + var substr string + var prefixWidth int + maxOffset := int32(len(text)) + for _, offset := range offsets { + b := util.Constrain32(offset.offset[0], index, maxOffset) + e := util.Constrain32(offset.offset[1], index, maxOffset) + + substr, prefixWidth = processTabs(text[index:b], prefixWidth) + C.CPrint(col1, bold, substr) + + if b < e { + substr, prefixWidth = processTabs(text[b:e], prefixWidth) + C.CPrint(offset.color, offset.bold, substr) + } + + index = e + if index >= maxOffset { + break + } + } + if index < maxOffset { + substr, _ = processTabs(text[index:], prefixWidth) + C.CPrint(col1, bold, substr) + } +} + +func processTabs(runes []rune, prefixWidth int) (string, int) { + var strbuf bytes.Buffer + l := prefixWidth + for _, r := range runes { + w := runeWidth(r, l) + l += w + if r == '\t' { + strbuf.WriteString(strings.Repeat(" ", w)) + } else { + strbuf.WriteRune(r) + } + } + return strbuf.String(), l +} + +func (t *Terminal) printAll() { + t.calculateMargins() + t.printList() + t.printPrompt() + t.printInfo() + t.printHeader() +} + +func (t *Terminal) refresh() { + if !t.suppress { + C.Refresh() + } +} + +func (t *Terminal) delChar() bool { + if len(t.input) > 0 && t.cx < len(t.input) { + t.input = append(t.input[:t.cx], t.input[t.cx+1:]...) + return true + } + return false +} + +func findLastMatch(pattern string, str string) int { + rx, err := regexp.Compile(pattern) + if err != nil { + return -1 + } + locs := rx.FindAllStringIndex(str, -1) + if locs == nil { + return -1 + } + return locs[len(locs)-1][0] +} + +func findFirstMatch(pattern string, str string) int { + rx, err := regexp.Compile(pattern) + if err != nil { + return -1 + } + loc := rx.FindStringIndex(str) + if loc == nil { + return -1 + } + return loc[0] +} + +func copySlice(slice []rune) []rune { + ret := make([]rune, len(slice)) + copy(ret, slice) + return ret +} + +func (t *Terminal) rubout(pattern string) { + pcx := t.cx + after := t.input[t.cx:] + t.cx = findLastMatch(pattern, string(t.input[:t.cx])) + 1 + t.yanked = copySlice(t.input[t.cx:pcx]) + t.input = append(t.input[:t.cx], after...) +} + +func keyMatch(key int, event C.Event) bool { + return event.Type == key || event.Type == C.Rune && int(event.Char) == key-C.AltZ +} + +func executeCommand(template string, current string) { + command := strings.Replace(template, "{}", fmt.Sprintf("%q", current), -1) + cmd := exec.Command("sh", "-c", command) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + C.Endwin() + cmd.Run() + C.Refresh() +} + +// Loop is called to start Terminal I/O +func (t *Terminal) Loop() { + <-t.startChan + { // Late initialization + t.mutex.Lock() + t.initFunc() + t.calculateMargins() + t.printPrompt() + t.placeCursor() + C.Refresh() + t.printInfo() + t.printHeader() + t.mutex.Unlock() + go func() { + timer := time.NewTimer(initialDelay) + <-timer.C + t.reqBox.Set(reqRefresh, nil) + }() + + resizeChan := make(chan os.Signal, 1) + signal.Notify(resizeChan, syscall.SIGWINCH) + go func() { + for { + <-resizeChan + t.reqBox.Set(reqRedraw, nil) + } + }() + + // Keep the spinner spinning + go func() { + for { + t.mutex.Lock() + reading := t.reading + t.mutex.Unlock() + if !reading { + break + } + time.Sleep(spinnerDuration) + t.reqBox.Set(reqInfo, nil) + } + }() + } + + exit := func(code int) { + if code <= exitNoMatch && t.history != nil { + t.history.append(string(t.input)) + } + os.Exit(code) + } + + go func() { + for { + t.reqBox.Wait(func(events *util.Events) { + defer events.Clear() + t.mutex.Lock() + for req := range *events { + switch req { + case reqPrompt: + t.printPrompt() + if t.inlineInfo { + t.printInfo() + } + case reqInfo: + t.printInfo() + case reqList: + t.printList() + case reqHeader: + t.printHeader() + case reqRefresh: + t.suppress = false + case reqRedraw: + C.Clear() + C.Endwin() + C.Refresh() + t.printAll() + case reqClose: + C.Close() + if t.output() { + exit(exitOk) + } + exit(exitNoMatch) + case reqQuit: + C.Close() + exit(exitInterrupt) + } + } + t.placeCursor() + t.mutex.Unlock() + }) + t.refresh() + } + }() + + looping := true + for looping { + event := C.GetChar() + + t.mutex.Lock() + previousInput := t.input + events := []util.EventType{reqPrompt} + req := func(evts ...util.EventType) { + for _, event := range evts { + events = append(events, event) + if event == reqClose || event == reqQuit { + looping = false + } + } + } + selectItem := func(item *Item) bool { + if _, found := t.selected[item.index]; !found { + t.selected[item.index] = selectedItem{time.Now(), item.StringPtr(t.ansi)} + return true + } + return false + } + toggleY := func(y int) { + item := t.merger.Get(y) + if !selectItem(item) { + delete(t.selected, item.index) + } + } + toggle := func() { + if t.cy < t.merger.Length() { + toggleY(t.cy) + req(reqInfo) + } + } + for key, ret := range t.expect { + if keyMatch(key, event) { + t.pressed = ret + req(reqClose) + break + } + } + + action := t.keymap[event.Type] + mapkey := event.Type + if event.Type == C.Rune { + mapkey = int(event.Char) + int(C.AltZ) + if act, prs := t.keymap[mapkey]; prs { + action = act + } + } + switch action { + case actIgnore: + case actExecute: + if t.cy >= 0 && t.cy < t.merger.Length() { + item := t.merger.Get(t.cy) + executeCommand(t.execmap[mapkey], item.AsString(t.ansi)) + } + case actInvalid: + t.mutex.Unlock() + continue + case actToggleSort: + t.sort = !t.sort + t.eventBox.Set(EvtSearchNew, t.sort) + t.mutex.Unlock() + continue + case actBeginningOfLine: + t.cx = 0 + case actBackwardChar: + if t.cx > 0 { + t.cx-- + } + case actAbort: + req(reqQuit) + case actDeleteChar: + t.delChar() + case actDeleteCharEOF: + if !t.delChar() && t.cx == 0 { + req(reqQuit) + } + case actEndOfLine: + t.cx = len(t.input) + case actCancel: + if len(t.input) == 0 { + req(reqQuit) + } else { + t.yanked = t.input + t.input = []rune{} + t.cx = 0 + } + case actForwardChar: + if t.cx < len(t.input) { + t.cx++ + } + case actBackwardDeleteChar: + if t.cx > 0 { + t.input = append(t.input[:t.cx-1], t.input[t.cx:]...) + t.cx-- + } + case actSelectAll: + if t.multi { + for i := 0; i < t.merger.Length(); i++ { + item := t.merger.Get(i) + selectItem(item) + } + req(reqList, reqInfo) + } + case actDeselectAll: + if t.multi { + for i := 0; i < t.merger.Length(); i++ { + item := t.merger.Get(i) + delete(t.selected, item.index) + } + req(reqList, reqInfo) + } + case actToggle: + if t.multi && t.merger.Length() > 0 { + toggle() + req(reqList) + } + case actToggleAll: + if t.multi { + for i := 0; i < t.merger.Length(); i++ { + toggleY(i) + } + req(reqList, reqInfo) + } + case actToggleDown: + if t.multi && t.merger.Length() > 0 { + toggle() + t.vmove(-1) + req(reqList) + } + case actToggleUp: + if t.multi && t.merger.Length() > 0 { + toggle() + t.vmove(1) + req(reqList) + } + case actDown: + t.vmove(-1) + req(reqList) + case actUp: + t.vmove(1) + req(reqList) + case actAccept: + req(reqClose) + case actClearScreen: + req(reqRedraw) + case actUnixLineDiscard: + if t.cx > 0 { + t.yanked = copySlice(t.input[:t.cx]) + t.input = t.input[t.cx:] + t.cx = 0 + } + case actUnixWordRubout: + if t.cx > 0 { + t.rubout("\\s\\S") + } + case actBackwardKillWord: + if t.cx > 0 { + t.rubout("[^[:alnum:]][[:alnum:]]") + } + case actYank: + suffix := copySlice(t.input[t.cx:]) + t.input = append(append(t.input[:t.cx], t.yanked...), suffix...) + t.cx += len(t.yanked) + case actPageUp: + t.vmove(t.maxItems() - 1) + req(reqList) + case actPageDown: + t.vmove(-(t.maxItems() - 1)) + req(reqList) + case actBackwardWord: + t.cx = findLastMatch("[^[:alnum:]][[:alnum:]]", string(t.input[:t.cx])) + 1 + case actForwardWord: + t.cx += findFirstMatch("[[:alnum:]][^[:alnum:]]|(.$)", string(t.input[t.cx:])) + 1 + case actKillWord: + ncx := t.cx + + findFirstMatch("[[:alnum:]][^[:alnum:]]|(.$)", string(t.input[t.cx:])) + 1 + if ncx > t.cx { + t.yanked = copySlice(t.input[t.cx:ncx]) + t.input = append(t.input[:t.cx], t.input[ncx:]...) + } + case actKillLine: + if t.cx < len(t.input) { + t.yanked = copySlice(t.input[t.cx:]) + t.input = t.input[:t.cx] + } + case actRune: + prefix := copySlice(t.input[:t.cx]) + t.input = append(append(prefix, event.Char), t.input[t.cx:]...) + t.cx++ + case actPreviousHistory: + if t.history != nil { + t.history.override(string(t.input)) + t.input = []rune(t.history.previous()) + t.cx = len(t.input) + } + case actNextHistory: + if t.history != nil { + t.history.override(string(t.input)) + t.input = []rune(t.history.next()) + t.cx = len(t.input) + } + case actMouse: + me := event.MouseEvent + mx, my := me.X, me.Y + if me.S != 0 { + // Scroll + if t.merger.Length() > 0 { + if t.multi && me.Mod { + toggle() + } + t.vmove(me.S) + req(reqList) + } + } else if mx >= t.marginInt[3] && mx < C.MaxX()-t.marginInt[1] && + my >= t.marginInt[0] && my < C.MaxY()-t.marginInt[2] { + mx -= t.marginInt[3] + my -= t.marginInt[0] + mx = util.Constrain(mx-len(t.prompt), 0, len(t.input)) + if !t.reverse { + my = t.maxHeight() - my - 1 + } + min := 2 + len(t.header) + if t.inlineInfo { + min-- + } + if me.Double { + // Double-click + if my >= min { + if t.vset(t.offset+my-min) && t.cy < t.merger.Length() { + req(reqClose) + } + } + } else if me.Down { + if my == 0 && mx >= 0 { + // Prompt + t.cx = mx + } else if my >= min { + // List + if t.vset(t.offset+my-min) && t.multi && me.Mod { + toggle() + } + req(reqList) + } + } + } + } + changed := string(previousInput) != string(t.input) + t.mutex.Unlock() // Must be unlocked before touching reqBox + + if changed { + t.eventBox.Set(EvtSearchNew, t.sort) + } + for _, event := range events { + t.reqBox.Set(event, nil) + } + } +} + +func (t *Terminal) constrain() { + count := t.merger.Length() + height := t.maxItems() + diffpos := t.cy - t.offset + + t.cy = util.Constrain(t.cy, 0, count-1) + + if t.cy > t.offset+(height-1) { + // Ceil + t.offset = t.cy - (height - 1) + } else if t.offset > t.cy { + // Floor + t.offset = t.cy + } + + // Adjustment + if count-t.offset < height { + t.offset = util.Max(0, count-height) + t.cy = util.Constrain(t.offset+diffpos, 0, count-1) + } + t.offset = util.Max(0, t.offset) +} + +func (t *Terminal) vmove(o int) { + if t.reverse { + o *= -1 + } + dest := t.cy + o + if t.cycle { + max := t.merger.Length() - 1 + if dest > max { + if t.cy == max { + dest = 0 + } + } else if dest < 0 { + if t.cy == 0 { + dest = max + } + } + } + t.vset(dest) +} + +func (t *Terminal) vset(o int) bool { + t.cy = util.Constrain(o, 0, t.merger.Length()-1) + return t.cy == o +} + +func (t *Terminal) maxItems() int { + max := t.maxHeight() - 2 - len(t.header) + if t.inlineInfo { + max++ + } + return util.Max(max, 0) +} diff --git a/src/tokenizer.go b/src/tokenizer.go new file mode 100644 index 0000000..a8d0400 --- /dev/null +++ b/src/tokenizer.go @@ -0,0 +1,239 @@ +package fzf + +import ( + "regexp" + "strconv" + "strings" + + "github.com/junegunn/fzf/src/util" +) + +const rangeEllipsis = 0 + +// Range represents nth-expression +type Range struct { + begin int + end int +} + +// Token contains the tokenized part of the strings and its prefix length +type Token struct { + text []rune + prefixLength int +} + +// Delimiter for tokenizing the input +type Delimiter struct { + regex *regexp.Regexp + str *string +} + +func newRange(begin int, end int) Range { + if begin == 1 { + begin = rangeEllipsis + } + if end == -1 { + end = rangeEllipsis + } + return Range{begin, end} +} + +// ParseRange parses nth-expression and returns the corresponding Range object +func ParseRange(str *string) (Range, bool) { + if (*str) == ".." { + return newRange(rangeEllipsis, rangeEllipsis), true + } else if strings.HasPrefix(*str, "..") { + end, err := strconv.Atoi((*str)[2:]) + if err != nil || end == 0 { + return Range{}, false + } + return newRange(rangeEllipsis, end), true + } else if strings.HasSuffix(*str, "..") { + begin, err := strconv.Atoi((*str)[:len(*str)-2]) + if err != nil || begin == 0 { + return Range{}, false + } + return newRange(begin, rangeEllipsis), true + } else if strings.Contains(*str, "..") { + ns := strings.Split(*str, "..") + if len(ns) != 2 { + return Range{}, false + } + begin, err1 := strconv.Atoi(ns[0]) + end, err2 := strconv.Atoi(ns[1]) + if err1 != nil || err2 != nil || begin == 0 || end == 0 { + return Range{}, false + } + return newRange(begin, end), true + } + + n, err := strconv.Atoi(*str) + if err != nil || n == 0 { + return Range{}, false + } + return newRange(n, n), true +} + +func withPrefixLengths(tokens [][]rune, begin int) []Token { + ret := make([]Token, len(tokens)) + + prefixLength := begin + for idx, token := range tokens { + // Need to define a new local variable instead of the reused token to take + // the pointer to it + ret[idx] = Token{text: token, prefixLength: prefixLength} + prefixLength += len(token) + } + return ret +} + +const ( + awkNil = iota + awkBlack + awkWhite +) + +func awkTokenizer(input []rune) ([][]rune, int) { + // 9, 32 + ret := [][]rune{} + str := []rune{} + prefixLength := 0 + state := awkNil + for _, r := range input { + white := r == 9 || r == 32 + switch state { + case awkNil: + if white { + prefixLength++ + } else { + state = awkBlack + str = append(str, r) + } + case awkBlack: + str = append(str, r) + if white { + state = awkWhite + } + case awkWhite: + if white { + str = append(str, r) + } else { + ret = append(ret, str) + state = awkBlack + str = []rune{r} + } + } + } + if len(str) > 0 { + ret = append(ret, str) + } + return ret, prefixLength +} + +// Tokenize tokenizes the given string with the delimiter +func Tokenize(runes []rune, delimiter Delimiter) []Token { + if delimiter.str == nil && delimiter.regex == nil { + // AWK-style (\S+\s*) + tokens, prefixLength := awkTokenizer(runes) + return withPrefixLengths(tokens, prefixLength) + } + + var tokens []string + if delimiter.str != nil { + tokens = strings.Split(string(runes), *delimiter.str) + for i := 0; i < len(tokens)-1; i++ { + tokens[i] = tokens[i] + *delimiter.str + } + } else if delimiter.regex != nil { + str := string(runes) + for len(str) > 0 { + loc := delimiter.regex.FindStringIndex(str) + if loc == nil { + loc = []int{0, len(str)} + } + last := util.Max(loc[1], 1) + tokens = append(tokens, str[:last]) + str = str[last:] + } + } + asRunes := make([][]rune, len(tokens)) + for i, token := range tokens { + asRunes[i] = []rune(token) + } + return withPrefixLengths(asRunes, 0) +} + +func joinTokens(tokens []Token) []rune { + ret := []rune{} + for _, token := range tokens { + ret = append(ret, token.text...) + } + return ret +} + +func joinTokensAsRunes(tokens []Token) []rune { + ret := []rune{} + for _, token := range tokens { + ret = append(ret, token.text...) + } + return ret +} + +// Transform is used to transform the input when --with-nth option is given +func Transform(tokens []Token, withNth []Range) []Token { + transTokens := make([]Token, len(withNth)) + numTokens := len(tokens) + for idx, r := range withNth { + part := []rune{} + minIdx := 0 + if r.begin == r.end { + idx := r.begin + if idx == rangeEllipsis { + part = append(part, joinTokensAsRunes(tokens)...) + } else { + if idx < 0 { + idx += numTokens + 1 + } + if idx >= 1 && idx <= numTokens { + minIdx = idx - 1 + part = append(part, tokens[idx-1].text...) + } + } + } else { + var begin, end int + if r.begin == rangeEllipsis { // ..N + begin, end = 1, r.end + if end < 0 { + end += numTokens + 1 + } + } else if r.end == rangeEllipsis { // N.. + begin, end = r.begin, numTokens + if begin < 0 { + begin += numTokens + 1 + } + } else { + begin, end = r.begin, r.end + if begin < 0 { + begin += numTokens + 1 + } + if end < 0 { + end += numTokens + 1 + } + } + minIdx = util.Max(0, begin-1) + for idx := begin; idx <= end; idx++ { + if idx >= 1 && idx <= numTokens { + part = append(part, tokens[idx-1].text...) + } + } + } + var prefixLength int + if minIdx < numTokens { + prefixLength = tokens[minIdx].prefixLength + } else { + prefixLength = 0 + } + transTokens[idx] = Token{part, prefixLength} + } + return transTokens +} diff --git a/src/tokenizer_test.go b/src/tokenizer_test.go new file mode 100644 index 0000000..0f95aa1 --- /dev/null +++ b/src/tokenizer_test.go @@ -0,0 +1,110 @@ +package fzf + +import "testing" + +func TestParseRange(t *testing.T) { + { + i := ".." + r, _ := ParseRange(&i) + if r.begin != rangeEllipsis || r.end != rangeEllipsis { + t.Errorf("%s", r) + } + } + { + i := "3.." + r, _ := ParseRange(&i) + if r.begin != 3 || r.end != rangeEllipsis { + t.Errorf("%s", r) + } + } + { + i := "3..5" + r, _ := ParseRange(&i) + if r.begin != 3 || r.end != 5 { + t.Errorf("%s", r) + } + } + { + i := "-3..-5" + r, _ := ParseRange(&i) + if r.begin != -3 || r.end != -5 { + t.Errorf("%s", r) + } + } + { + i := "3" + r, _ := ParseRange(&i) + if r.begin != 3 || r.end != 3 { + t.Errorf("%s", r) + } + } +} + +func TestTokenize(t *testing.T) { + // AWK-style + input := " abc: def: ghi " + tokens := Tokenize([]rune(input), Delimiter{}) + if string(tokens[0].text) != "abc: " || tokens[0].prefixLength != 2 { + t.Errorf("%s", tokens) + } + + // With delimiter + tokens = Tokenize([]rune(input), delimiterRegexp(":")) + if string(tokens[0].text) != " abc:" || tokens[0].prefixLength != 0 { + t.Errorf("%s", tokens) + } + + // With delimiter regex + tokens = Tokenize([]rune(input), delimiterRegexp("\\s+")) + if string(tokens[0].text) != " " || tokens[0].prefixLength != 0 || + string(tokens[1].text) != "abc: " || tokens[1].prefixLength != 2 || + string(tokens[2].text) != "def: " || tokens[2].prefixLength != 8 || + string(tokens[3].text) != "ghi " || tokens[3].prefixLength != 14 { + t.Errorf("%s", tokens) + } +} + +func TestTransform(t *testing.T) { + input := " abc: def: ghi: jkl" + { + tokens := Tokenize([]rune(input), Delimiter{}) + { + ranges := splitNth("1,2,3") + tx := Transform(tokens, ranges) + if string(joinTokens(tx)) != "abc: def: ghi: " { + t.Errorf("%s", tx) + } + } + { + ranges := splitNth("1..2,3,2..,1") + tx := Transform(tokens, ranges) + if string(joinTokens(tx)) != "abc: def: ghi: def: ghi: jklabc: " || + len(tx) != 4 || + string(tx[0].text) != "abc: def: " || tx[0].prefixLength != 2 || + string(tx[1].text) != "ghi: " || tx[1].prefixLength != 14 || + string(tx[2].text) != "def: ghi: jkl" || tx[2].prefixLength != 8 || + string(tx[3].text) != "abc: " || tx[3].prefixLength != 2 { + t.Errorf("%s", tx) + } + } + } + { + tokens := Tokenize([]rune(input), delimiterRegexp(":")) + { + ranges := splitNth("1..2,3,2..,1") + tx := Transform(tokens, ranges) + if string(joinTokens(tx)) != " abc: def: ghi: def: ghi: jkl abc:" || + len(tx) != 4 || + string(tx[0].text) != " abc: def:" || tx[0].prefixLength != 0 || + string(tx[1].text) != " ghi:" || tx[1].prefixLength != 12 || + string(tx[2].text) != " def: ghi: jkl" || tx[2].prefixLength != 6 || + string(tx[3].text) != " abc:" || tx[3].prefixLength != 0 { + t.Errorf("%s", tx) + } + } + } +} + +func TestTransformIndexOutOfBounds(t *testing.T) { + Transform([]Token{}, splitNth("1")) +} diff --git a/src/update_assets.rb b/src/update_assets.rb new file mode 100755 index 0000000..c849f9e --- /dev/null +++ b/src/update_assets.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby + +# http://www.rubydoc.info/github/rest-client/rest-client/RestClient +require 'rest_client' + +if ARGV.length < 3 + puts "usage: #$0 " + exit 1 +end + +token, version, *files = ARGV +base = "https://api.github.com/repos/junegunn/fzf-bin/releases" + +# List releases +rels = JSON.parse(RestClient.get(base, :authorization => "token #{token}")) +rel = rels.find { |r| r['tag_name'] == version } +unless rel + puts "#{version} not found" + exit 1 +end + +# List assets +assets = Hash[rel['assets'].map { |a| a.values_at *%w[name id] }] + +files.select { |f| File.exists? f }.each do |file| + name = File.basename file + + if asset_id = assets[name] + puts "#{name} found. Deleting asset id #{asset_id}." + RestClient.delete "#{base}/assets/#{asset_id}", + :authorization => "token #{token}" + else + puts "#{name} not found" + end + + puts "Uploading #{name}" + RestClient.post( + "#{base.sub 'api', 'uploads'}/#{rel['id']}/assets?name=#{name}", + File.read(file), + :authorization => "token #{token}", + :content_type => "application/octet-stream") +end diff --git a/src/util/atomicbool.go b/src/util/atomicbool.go new file mode 100644 index 0000000..9e1bdc8 --- /dev/null +++ b/src/util/atomicbool.go @@ -0,0 +1,32 @@ +package util + +import "sync" + +// AtomicBool is a boxed-class that provides synchronized access to the +// underlying boolean value +type AtomicBool struct { + mutex sync.Mutex + state bool +} + +// NewAtomicBool returns a new AtomicBool +func NewAtomicBool(initialState bool) *AtomicBool { + return &AtomicBool{ + mutex: sync.Mutex{}, + state: initialState} +} + +// Get returns the current boolean value synchronously +func (a *AtomicBool) Get() bool { + a.mutex.Lock() + defer a.mutex.Unlock() + return a.state +} + +// Set updates the boolean value synchronously +func (a *AtomicBool) Set(newState bool) bool { + a.mutex.Lock() + defer a.mutex.Unlock() + a.state = newState + return a.state +} diff --git a/src/util/atomicbool_test.go b/src/util/atomicbool_test.go new file mode 100644 index 0000000..1feff79 --- /dev/null +++ b/src/util/atomicbool_test.go @@ -0,0 +1,17 @@ +package util + +import "testing" + +func TestAtomicBool(t *testing.T) { + if !NewAtomicBool(true).Get() || NewAtomicBool(false).Get() { + t.Error("Invalid initial value") + } + + ab := NewAtomicBool(true) + if ab.Set(false) { + t.Error("Invalid return value") + } + if ab.Get() { + t.Error("Invalid state") + } +} diff --git a/src/util/eventbox.go b/src/util/eventbox.go new file mode 100644 index 0000000..ccdbb99 --- /dev/null +++ b/src/util/eventbox.go @@ -0,0 +1,96 @@ +package util + +import "sync" + +// EventType is the type for fzf events +type EventType int + +// Events is a type that associates EventType to any data +type Events map[EventType]interface{} + +// EventBox is used for coordinating events +type EventBox struct { + events Events + cond *sync.Cond + ignore map[EventType]bool +} + +// NewEventBox returns a new EventBox +func NewEventBox() *EventBox { + return &EventBox{ + events: make(Events), + cond: sync.NewCond(&sync.Mutex{}), + ignore: make(map[EventType]bool)} +} + +// Wait blocks the goroutine until signaled +func (b *EventBox) Wait(callback func(*Events)) { + b.cond.L.Lock() + defer b.cond.L.Unlock() + + if len(b.events) == 0 { + b.cond.Wait() + } + + callback(&b.events) +} + +// Set turns on the event type on the box +func (b *EventBox) Set(event EventType, value interface{}) { + b.cond.L.Lock() + defer b.cond.L.Unlock() + b.events[event] = value + if _, found := b.ignore[event]; !found { + b.cond.Broadcast() + } +} + +// Clear clears the events +// Unsynchronized; should be called within Wait routine +func (events *Events) Clear() { + for event := range *events { + delete(*events, event) + } +} + +// Peek peeks at the event box if the given event is set +func (b *EventBox) Peek(event EventType) bool { + b.cond.L.Lock() + defer b.cond.L.Unlock() + _, ok := b.events[event] + return ok +} + +// Watch deletes the events from the ignore list +func (b *EventBox) Watch(events ...EventType) { + b.cond.L.Lock() + defer b.cond.L.Unlock() + for _, event := range events { + delete(b.ignore, event) + } +} + +// Unwatch adds the events to the ignore list +func (b *EventBox) Unwatch(events ...EventType) { + b.cond.L.Lock() + defer b.cond.L.Unlock() + for _, event := range events { + b.ignore[event] = true + } +} + +// WaitFor blocks the execution until the event is received +func (b *EventBox) WaitFor(event EventType) { + looping := true + for looping { + b.Wait(func(events *Events) { + for evt := range *events { + switch evt { + case event: + looping = false + return + } + } + }) + } +} diff --git a/src/util/eventbox_test.go b/src/util/eventbox_test.go new file mode 100644 index 0000000..5a9dc30 --- /dev/null +++ b/src/util/eventbox_test.go @@ -0,0 +1,61 @@ +package util + +import "testing" + +// fzf events +const ( + EvtReadNew EventType = iota + EvtReadFin + EvtSearchNew + EvtSearchProgress + EvtSearchFin + EvtClose +) + +func TestEventBox(t *testing.T) { + eb := NewEventBox() + + // Wait should return immediately + ch := make(chan bool) + + go func() { + eb.Set(EvtReadNew, 10) + ch <- true + <-ch + eb.Set(EvtSearchNew, 10) + eb.Set(EvtSearchNew, 15) + eb.Set(EvtSearchNew, 20) + eb.Set(EvtSearchProgress, 30) + ch <- true + <-ch + eb.Set(EvtSearchFin, 40) + ch <- true + <-ch + }() + + count := 0 + sum := 0 + looping := true + for looping { + <-ch + eb.Wait(func(events *Events) { + for _, value := range *events { + switch val := value.(type) { + case int: + sum += val + looping = sum < 100 + } + } + events.Clear() + }) + ch <- true + count++ + } + + if count != 3 { + t.Error("Invalid number of events", count) + } + if sum != 100 { + t.Error("Invalid sum", sum) + } +} diff --git a/src/util/util.go b/src/util/util.go new file mode 100644 index 0000000..aa5f227 --- /dev/null +++ b/src/util/util.go @@ -0,0 +1,102 @@ +package util + +// #include +import "C" + +import ( + "os" + "time" + "unicode/utf8" +) + +// Max returns the largest integer +func Max(first int, items ...int) int { + max := first + for _, item := range items { + if item > max { + max = item + } + } + return max +} + +// Min32 returns the smallest 32-bit integer +func Min32(first int32, second int32) int32 { + if first <= second { + return first + } + return second +} + +// Max32 returns the largest 32-bit integer +func Max32(first int32, second int32) int32 { + if first > second { + return first + } + return second +} + +// Constrain32 limits the given 32-bit integer with the upper and lower bounds +func Constrain32(val int32, min int32, max int32) int32 { + if val < min { + return min + } + if val > max { + return max + } + return val +} + +// Constrain limits the given integer with the upper and lower bounds +func Constrain(val int, min int, max int) int { + if val < min { + return min + } + if val > max { + return max + } + return val +} + +// DurWithin limits the given time.Duration with the upper and lower bounds +func DurWithin( + val time.Duration, min time.Duration, max time.Duration) time.Duration { + if val < min { + return min + } + if val > max { + return max + } + return val +} + +// IsTty returns true is stdin is a terminal +func IsTty() bool { + return int(C.isatty(C.int(os.Stdin.Fd()))) != 0 +} + +func TrimRight(runes []rune) []rune { + var i int + for i = len(runes) - 1; i >= 0; i-- { + char := runes[i] + if char != ' ' && char != '\t' { + break + } + } + return runes[0 : i+1] +} + +func BytesToRunes(bytea []byte) []rune { + runes := make([]rune, 0, len(bytea)) + for i := 0; i < len(bytea); { + if bytea[i] < utf8.RuneSelf { + runes = append(runes, rune(bytea[i])) + i++ + } else { + r, sz := utf8.DecodeRune(bytea[i:]) + i += sz + runes = append(runes, r) + } + } + return runes +} diff --git a/src/util/util_test.go b/src/util/util_test.go new file mode 100644 index 0000000..06cfd4f --- /dev/null +++ b/src/util/util_test.go @@ -0,0 +1,22 @@ +package util + +import "testing" + +func TestMax(t *testing.T) { + if Max(-2, 5, 1, 4, 3) != 5 { + t.Error("Invalid result") + } +} + +func TestContrain(t *testing.T) { + if Constrain(-3, -1, 3) != -1 { + t.Error("Expected", -1) + } + if Constrain(2, -1, 3) != 2 { + t.Error("Expected", 2) + } + + if Constrain(5, -1, 3) != 3 { + t.Error("Expected", 3) + } +} diff --git a/test/fzf.vader b/test/fzf.vader index 5d40142..c27fddc 100644 --- a/test/fzf.vader +++ b/test/fzf.vader @@ -3,20 +3,23 @@ Execute (Setup): Log 'Test directory: ' . g:dir Execute (fzf#run with dir option): + let cwd = getcwd() let result = fzf#run({ 'options': '--filter=vdr', 'dir': g:dir }) AssertEqual ['fzf.vader'], result + AssertEqual getcwd(), cwd let result = sort(fzf#run({ 'options': '--filter e', 'dir': g:dir })) - AssertEqual ['fzf.vader', 'test_fzf.rb'], result + AssertEqual ['fzf.vader', 'test_go.rb'], result + AssertEqual getcwd(), cwd Execute (fzf#run with Funcref command): let g:ret = [] - function! g:proc(e) + function! g:FzfTest(e) call add(g:ret, a:e) endfunction - let result = sort(fzf#run({ 'sink': function('g:proc'), 'options': '--filter e', 'dir': g:dir })) - AssertEqual ['fzf.vader', 'test_fzf.rb'], result - AssertEqual ['fzf.vader', 'test_fzf.rb'], sort(g:ret) + let result = sort(fzf#run({ 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir })) + AssertEqual ['fzf.vader', 'test_go.rb'], result + AssertEqual ['fzf.vader', 'test_go.rb'], sort(g:ret) Execute (fzf#run with string source): let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' })) diff --git a/test/test_fzf.rb b/test/test_fzf.rb deleted file mode 100644 index e11f2c5..0000000 --- a/test/test_fzf.rb +++ /dev/null @@ -1,850 +0,0 @@ -#!/usr/bin/env ruby -# encoding: utf-8 - -require 'rubygems' -require 'curses' -require 'timeout' -require 'stringio' -require 'minitest/autorun' -require 'tempfile' -$LOAD_PATH.unshift File.expand_path('../..', __FILE__) -ENV['FZF_EXECUTABLE'] = '0' -load 'fzf' - -class MockTTY - def initialize - @buffer = '' - @mutex = Mutex.new - @condv = ConditionVariable.new - end - - def read_nonblock sz - @mutex.synchronize do - take sz - end - end - - def take sz - if @buffer.length >= sz - ret = @buffer[0, sz] - @buffer = @buffer[sz..-1] - ret - end - end - - def getc - sleep 0.1 - while true - @mutex.synchronize do - if char = take(1) - return char - else - @condv.wait(@mutex) - end - end - end - end - - def << str - @mutex.synchronize do - @buffer << str - @condv.broadcast - end - self - end -end - -class TestFZF < MiniTest::Unit::TestCase - def setup - ENV.delete 'FZF_DEFAULT_SORT' - ENV.delete 'FZF_DEFAULT_OPTS' - ENV.delete 'FZF_DEFAULT_COMMAND' - end - - def test_default_options - fzf = FZF.new [] - assert_equal 1000, fzf.sort - assert_equal false, fzf.multi - assert_equal true, fzf.color - assert_equal nil, fzf.rxflag - assert_equal true, fzf.mouse - assert_equal nil, fzf.nth - assert_equal nil, fzf.with_nth - assert_equal true, fzf.color - assert_equal false, fzf.black - assert_equal true, fzf.ansi256 - assert_equal '', fzf.query - assert_equal false, fzf.select1 - assert_equal false, fzf.exit0 - assert_equal nil, fzf.filter - assert_equal nil, fzf.extended - assert_equal false, fzf.reverse - assert_equal '> ', fzf.prompt - assert_equal false, fzf.print_query - end - - def test_environment_variables - # Deprecated - ENV['FZF_DEFAULT_SORT'] = '20000' - fzf = FZF.new [] - assert_equal 20000, fzf.sort - assert_equal nil, fzf.nth - - ENV['FZF_DEFAULT_OPTS'] = - '-x -m -s 10000 -q " hello world " +c +2 --select-1 -0 ' << - '--no-mouse -f "goodbye world" --black --with-nth=3,-3..,2 --nth=3,-1,2 --reverse --print-query' - fzf = FZF.new [] - assert_equal 10000, fzf.sort - assert_equal ' hello world ', - fzf.query - assert_equal 'goodbye world', - fzf.filter - assert_equal :fuzzy, fzf.extended - assert_equal true, fzf.multi - assert_equal false, fzf.color - assert_equal false, fzf.ansi256 - assert_equal true, fzf.black - assert_equal false, fzf.mouse - assert_equal true, fzf.select1 - assert_equal true, fzf.exit0 - assert_equal true, fzf.reverse - assert_equal true, fzf.print_query - assert_equal [2..2, -1..-1, 1..1], fzf.nth - assert_equal [2..2, -3..-1, 1..1], fzf.with_nth - end - - def test_option_parser - # Long opts - fzf = FZF.new %w[--sort=2000 --no-color --multi +i --query hello --select-1 - --exit-0 --filter=howdy --extended-exact - --no-mouse --no-256 --nth=1 --with-nth=.. --reverse --prompt (hi) - --print-query] - assert_equal 2000, fzf.sort - assert_equal true, fzf.multi - assert_equal false, fzf.color - assert_equal false, fzf.ansi256 - assert_equal false, fzf.black - assert_equal false, fzf.mouse - assert_equal 0, fzf.rxflag - assert_equal 'hello', fzf.query - assert_equal true, fzf.select1 - assert_equal true, fzf.exit0 - assert_equal 'howdy', fzf.filter - assert_equal :exact, fzf.extended - assert_equal [0..0], fzf.nth - assert_equal nil, fzf.with_nth - assert_equal true, fzf.reverse - assert_equal '(hi)', fzf.prompt - assert_equal true, fzf.print_query - - # Long opts (left-to-right) - fzf = FZF.new %w[--sort=2000 --no-color --multi +i --query=hello - --filter a --filter b --no-256 --black --nth -1 --nth -2 - --select-1 --exit-0 --no-select-1 --no-exit-0 - --no-sort -i --color --no-multi --256 - --reverse --no-reverse --prompt (hi) --prompt=(HI) - --print-query --no-print-query] - assert_equal nil, fzf.sort - assert_equal false, fzf.multi - assert_equal true, fzf.color - assert_equal true, fzf.ansi256 - assert_equal true, fzf.black - assert_equal true, fzf.mouse - assert_equal 1, fzf.rxflag - assert_equal 'b', fzf.filter - assert_equal 'hello', fzf.query - assert_equal false, fzf.select1 - assert_equal false, fzf.exit0 - assert_equal nil, fzf.extended - assert_equal [-2..-2], fzf.nth - assert_equal false, fzf.reverse - assert_equal '(HI)', fzf.prompt - assert_equal false, fzf.print_query - - # Short opts - fzf = FZF.new %w[-s2000 +c -m +i -qhello -x -fhowdy +2 -n3 -1 -0] - assert_equal 2000, fzf.sort - assert_equal true, fzf.multi - assert_equal false, fzf.color - assert_equal false, fzf.ansi256 - assert_equal 0, fzf.rxflag - assert_equal 'hello', fzf.query - assert_equal 'howdy', fzf.filter - assert_equal :fuzzy, fzf.extended - assert_equal [2..2], fzf.nth - assert_equal true, fzf.select1 - assert_equal true, fzf.exit0 - - # Left-to-right - fzf = FZF.new %w[-s 2000 +c -m +i -qhello -x -fgoodbye +2 -n3 -n4,5 - -s 3000 -c +m -i -q world +x -fworld -2 --black --no-black - -1 -0 +1 +0 - ] - assert_equal 3000, fzf.sort - assert_equal false, fzf.multi - assert_equal true, fzf.color - assert_equal true, fzf.ansi256 - assert_equal false, fzf.black - assert_equal 1, fzf.rxflag - assert_equal 'world', fzf.query - assert_equal false, fzf.select1 - assert_equal false, fzf.exit0 - assert_equal 'world', fzf.filter - assert_equal nil, fzf.extended - assert_equal [3..3, 4..4], fzf.nth - rescue SystemExit => e - assert false, "Exited" - end - - def test_invalid_option - [ - %w[--unknown], - %w[yo dawg], - %w[--nth=0], - %w[-n 0], - %w[-n 1..2..3], - %w[-n 1....], - %w[-n ....3], - %w[-n 1....3], - %w[-n 1..0], - %w[--nth ..0], - ].each do |argv| - assert_raises(SystemExit) do - fzf = FZF.new argv - end - end - end - - def test_width - fzf = FZF.new [] - assert_equal 5, fzf.width('abcde') - assert_equal 4, fzf.width('한글') - assert_equal 5, fzf.width('한글.') - end if RUBY_VERSION >= '1.9' - - def test_trim - fzf = FZF.new [] - assert_equal ['사.', 6], fzf.trim('가나다라마바사.', 4, true) - assert_equal ['바사.', 5], fzf.trim('가나다라마바사.', 5, true) - assert_equal ['바사.', 5], fzf.trim('가나다라마바사.', 6, true) - assert_equal ['마바사.', 4], fzf.trim('가나다라마바사.', 7, true) - assert_equal ['가나', 6], fzf.trim('가나다라마바사.', 4, false) - assert_equal ['가나', 6], fzf.trim('가나다라마바사.', 5, false) - assert_equal ['가나a', 6], fzf.trim('가나ab라마바사.', 5, false) - assert_equal ['가나ab', 5], fzf.trim('가나ab라마바사.', 6, false) - assert_equal ['가나ab', 5], fzf.trim('가나ab라마바사.', 7, false) - end if RUBY_VERSION >= '1.9' - - def test_format - fzf = FZF.new [] - assert_equal [['01234..', false]], fzf.format('0123456789', 7, []) - assert_equal [['012', false], ['34', true], ['..', false]], - fzf.format('0123456789', 7, [[3, 5]]) - assert_equal [['..56', false], ['789', true]], - fzf.format('0123456789', 7, [[7, 10]]) - assert_equal [['..56', false], ['78', true], ['9', false]], - fzf.format('0123456789', 7, [[7, 9]]) - - (3..5).each do |i| - assert_equal [['..', false], ['567', true], ['89', false]], - fzf.format('0123456789', 7, [[i, 8]]) - end - - assert_equal [['..', false], ['345', true], ['..', false]], - fzf.format('0123456789', 7, [[3, 6]]) - assert_equal [['012', false], ['34', true], ['..', false]], - fzf.format('0123456789', 7, [[3, 5]]) - - # Multi-region - assert_equal [["0", true], ["1", false], ["2", true], ["34..", false]], - fzf.format('0123456789', 7, [[0, 1], [2, 3]]) - - assert_equal [["..", false], ["5", true], ["6", false], ["78", true], ["9", false]], - fzf.format('0123456789', 7, [[3, 6], [7, 9]]) - - assert_equal [["..", false], ["3", true], ["4", false], ["5", true], ["..", false]], - fzf.format('0123456789', 7, [[3, 4], [5, 6]]) - - # Multi-region Overlap - assert_equal [["..", false], ["345", true], ["..", false]], - fzf.format('0123456789', 7, [[4, 5], [3, 6]]) - end - - def test_fuzzy_matcher - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE - list = %w[ - juice - juiceful - juiceless - juicily - juiciness - juicy] - assert matcher.caches.empty? - assert_equal( - [["juice", [[0, 1]]], - ["juiceful", [[0, 1]]], - ["juiceless", [[0, 1]]], - ["juicily", [[0, 1]]], - ["juiciness", [[0, 1]]], - ["juicy", [[0, 1]]]], matcher.match(list, 'j', '', '').sort) - assert !matcher.caches.empty? - assert_equal [list.object_id], matcher.caches.keys - assert_equal 1, matcher.caches[list.object_id].length - assert_equal 6, matcher.caches[list.object_id]['j'].length - - assert_equal( - [["juicily", [[0, 5]]], - ["juiciness", [[0, 5]]]], matcher.match(list, 'jii', '', '').sort) - - assert_equal( - [["juicily", [[2, 5]]], - ["juiciness", [[2, 5]]]], matcher.match(list, 'ii', '', '').sort) - - assert_equal 3, matcher.caches[list.object_id].length - assert_equal 2, matcher.caches[list.object_id]['ii'].length - - # TODO : partial_cache - end - - def test_fuzzy_matcher_rxflag - assert_equal nil, FZF::FuzzyMatcher.new(nil).rxflag - assert_equal 0, FZF::FuzzyMatcher.new(0).rxflag - assert_equal 1, FZF::FuzzyMatcher.new(1).rxflag - - assert_equal 1, FZF::FuzzyMatcher.new(nil).rxflag_for('abc') - assert_equal 0, FZF::FuzzyMatcher.new(nil).rxflag_for('Abc') - assert_equal 0, FZF::FuzzyMatcher.new(0).rxflag_for('abc') - assert_equal 0, FZF::FuzzyMatcher.new(0).rxflag_for('Abc') - assert_equal 1, FZF::FuzzyMatcher.new(1).rxflag_for('abc') - assert_equal 1, FZF::FuzzyMatcher.new(1).rxflag_for('Abc') - end - - def test_fuzzy_matcher_case_sensitive - # Smart-case match (Uppercase found) - assert_equal [['Fruit', [[0, 5]]]], - FZF::FuzzyMatcher.new(nil).match(%w[Fruit Grapefruit], 'Fruit', '', '').sort - - # Smart-case match (Uppercase not-found) - assert_equal [["Fruit", [[0, 5]]], ["Grapefruit", [[5, 10]]]], - FZF::FuzzyMatcher.new(nil).match(%w[Fruit Grapefruit], 'fruit', '', '').sort - - # Case-sensitive match (-i) - assert_equal [['Fruit', [[0, 5]]]], - FZF::FuzzyMatcher.new(0).match(%w[Fruit Grapefruit], 'Fruit', '', '').sort - - # Case-insensitive match (+i) - assert_equal [["Fruit", [[0, 5]]], ["Grapefruit", [[5, 10]]]], - FZF::FuzzyMatcher.new(Regexp::IGNORECASE). - match(%w[Fruit Grapefruit], 'Fruit', '', '').sort - end - - def test_extended_fuzzy_matcher_case_sensitive - %w['Fruit Fruit$].each do |q| - # Smart-case match (Uppercase found) - assert_equal [['Fruit', [[0, 5]]]], - FZF::ExtendedFuzzyMatcher.new(nil).match(%w[Fruit Grapefruit], q, '', '').sort - - # Smart-case match (Uppercase not-found) - assert_equal [["Fruit", [[0, 5]]], ["Grapefruit", [[5, 10]]]], - FZF::ExtendedFuzzyMatcher.new(nil).match(%w[Fruit Grapefruit], q.downcase, '', '').sort - - # Case-sensitive match (-i) - assert_equal [['Fruit', [[0, 5]]]], - FZF::ExtendedFuzzyMatcher.new(0).match(%w[Fruit Grapefruit], q, '', '').sort - - # Case-insensitive match (+i) - assert_equal [["Fruit", [[0, 5]]], ["Grapefruit", [[5, 10]]]], - FZF::ExtendedFuzzyMatcher.new(Regexp::IGNORECASE). - match(%w[Fruit Grapefruit], q, '', '').sort - end - end - - def test_extended_fuzzy_matcher - matcher = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE - list = %w[ - juice - juiceful - juiceless - juicily - juiciness - juicy - _juice] - match = proc { |q, prefix| - matcher.match(list, q, prefix, '').sort.map { |p| [p.first, p.last.sort] } - } - - assert matcher.caches.empty? - 3.times do - ['y j', 'j y'].each do |pat| - (0..pat.length - 1).each do |prefix_length| - prefix = pat[0, prefix_length] - assert_equal( - [["juicily", [[0, 1], [6, 7]]], - ["juicy", [[0, 1], [4, 5]]]], - match.call(pat, prefix)) - end - end - - # $ - assert_equal [["juiceful", [[7, 8]]]], match.call('l$', '') - assert_equal [["juiceful", [[7, 8]]], - ["juiceless", [[5, 6]]], - ["juicily", [[5, 6]]]], match.call('l', '') - - # ^ - assert_equal list.length, match.call('j', '').length - assert_equal list.length - 1, match.call('^j', '').length - - # ^ + $ - assert_equal 0, match.call('^juici$', '').length - assert_equal 1, match.call('^juice$', '').length - assert_equal 0, match.call('^.*$', '').length - - # ! - assert_equal 0, match.call('!j', '').length - - # ! + ^ - assert_equal [["_juice", []]], match.call('!^j', '') - - # ! + $ - assert_equal list.length - 1, match.call('!l$', '').length - - # ! + f - assert_equal [["juicy", [[4, 5]]]], match.call('y !l', '') - - # ' - assert_equal %w[juiceful juiceless juicily], - match.call('il', '').map { |e| e.first } - assert_equal %w[juicily], - match.call("'il", '').map { |e| e.first } - assert_equal (list - %w[juicily]).sort, - match.call("!'il", '').map { |e| e.first }.sort - end - assert !matcher.caches.empty? - end - - def test_xfuzzy_matcher_prefix_cache - matcher = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE - list = %w[ - a.java - b.java - java.jive - c.java$ - d.java - ] - 2.times do - assert_equal 5, matcher.match(list, 'java', 'java', '').length - assert_equal 3, matcher.match(list, 'java$', 'java$', '').length - assert_equal 1, matcher.match(list, 'java$$', 'java$$', '').length - - assert_equal 0, matcher.match(list, '!java', '!java', '').length - assert_equal 4, matcher.match(list, '!^jav', '!^jav', '').length - assert_equal 4, matcher.match(list, '!^java', '!^java', '').length - assert_equal 2, matcher.match(list, '!^java !b !c', '!^java', '').length - end - end - - def test_sort_by_rank - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE - xmatcher = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE - list = %w[ - 0____1 - 0_____1 - 01 - ____0_1 - 01_ - _01_ - 0______1 - ___01___ - ] - assert_equal( - [["01", [[0, 2]]], - ["01_", [[0, 2]]], - ["_01_", [[1, 3]]], - ["___01___", [[3, 5]]], - ["____0_1", [[4, 7]]], - ["0____1", [[0, 6]]], - ["0_____1", [[0, 7]]], - ["0______1", [[0, 8]]]], - FZF.sort(matcher.match(list, '01', '', ''))) - - assert_equal( - [["01", [[0, 1], [1, 2]]], - ["01_", [[0, 1], [1, 2]]], - ["_01_", [[1, 2], [2, 3]]], - ["0____1", [[0, 1], [5, 6]]], - ["0_____1", [[0, 1], [6, 7]]], - ["____0_1", [[4, 5], [6, 7]]], - ["0______1", [[0, 1], [7, 8]]], - ["___01___", [[3, 4], [4, 5]]]], - FZF.sort(xmatcher.match(list, '0 1', '', ''))) - - assert_equal( - [["_01_", [[1, 3], [0, 4]], [4, 4, "_01_"]], - ["___01___", [[3, 5], [0, 2]], [4, 8, "___01___"]], - ["____0_1", [[4, 7], [0, 2]], [5, 7, "____0_1"]], - ["0____1", [[0, 6], [1, 3]], [6, 6, "0____1"]], - ["0_____1", [[0, 7], [1, 3]], [7, 7, "0_____1"]], - ["0______1", [[0, 8], [1, 3]], [8, 8, "0______1"]]], - FZF.sort(xmatcher.match(list, '01 __', '', '')).map { |tuple| - tuple << FZF.rank(tuple) - } - ) - end - - def test_extended_exact_mode - exact = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE, :exact - fuzzy = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE, :fuzzy - list = %w[ - extended-exact-mode-not-fuzzy - extended'-fuzzy-mode - ] - assert_equal 2, fuzzy.match(list, 'extended', '', '').length - assert_equal 2, fuzzy.match(list, 'mode extended', '', '').length - assert_equal 2, fuzzy.match(list, 'xtndd', '', '').length - assert_equal 2, fuzzy.match(list, "'-fuzzy", '', '').length - - assert_equal 2, exact.match(list, 'extended', '', '').length - assert_equal 2, exact.match(list, 'mode extended', '', '').length - assert_equal 0, exact.match(list, 'xtndd', '', '').length - assert_equal 1, exact.match(list, "'-fuzzy", '', '').length - assert_equal 2, exact.match(list, "-fuzzy", '', '').length - end - - # ^$ -> matches empty item - def test_format_empty_item - fzf = FZF.new [] - item = ['', [[0, 0]]] - line, offsets = item - tokens = fzf.format line, 80, offsets - assert_equal [], tokens - end - - def test_mouse_event - interval = FZF::MouseEvent::DOUBLE_CLICK_INTERVAL - me = FZF::MouseEvent.new nil - me.v = 10 - assert_equal false, me.double?(10) - assert_equal false, me.double?(20) - me.v = 20 - assert_equal false, me.double?(10) - assert_equal false, me.double?(20) - me.v = 20 - assert_equal false, me.double?(10) - assert_equal true, me.double?(20) - sleep interval - assert_equal false, me.double?(20) - end - - def test_nth_match - list = [ - ' first second third', - 'fourth fifth sixth', - ] - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE - assert_equal list, matcher.match(list, 'f', '', '').map(&:first) - assert_equal [ - [list[0], [[2, 5]]], - [list[1], [[9, 17]]]], matcher.match(list, 'is', '', '') - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [1..1] - assert_equal [[list[1], [[8, 9]]]], matcher.match(list, 'f', '', '') - assert_equal [[list[0], [[8, 9]]]], matcher.match(list, 's', '', '') - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [2..2] - assert_equal [[list[0], [[19, 20]]]], matcher.match(list, 'r', '', '') - - # Comma-separated - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [2..2, 0..0] - assert_equal [[list[0], [[19, 20]]], [list[1], [[3, 4]]]], matcher.match(list, 'r', '', '') - - # Ordered - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [0..0, 2..2] - assert_equal [[list[0], [[3, 4]]], [list[1], [[3, 4]]]], matcher.match(list, 'r', '', '') - - regex = FZF.build_delim_regex "\t" - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [0..0], regex - assert_equal [[list[0], [[3, 10]]]], matcher.match(list, 're', '', '') - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [1..1], regex - assert_equal [], matcher.match(list, 'r', '', '') - assert_equal [[list[1], [[9, 17]]]], matcher.match(list, 'is', '', '') - - # Negative indexing - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [-1..-1], regex - assert_equal [[list[0], [[3, 6]]]], matcher.match(list, 'rt', '', '') - assert_equal [[list[0], [[2, 5]]], [list[1], [[9, 17]]]], matcher.match(list, 'is', '', '') - - # Regex delimiter - regex = FZF.build_delim_regex "[ \t]+" - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [0..0], regex - assert_equal [list[1]], matcher.match(list, 'f', '', '').map(&:first) - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [1..1], regex - assert_equal [[list[0], [[1, 2]]], [list[1], [[8, 9]]]], matcher.match(list, 'f', '', '') - end - - def test_nth_match_range - list = [ - ' first second third', - 'fourth fifth sixth', - ] - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [1..2] - assert_equal [[list[0], [[8, 20]]]], matcher.match(list, 'sr', '', '') - assert_equal [], matcher.match(list, 'fo', '', '') - - matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [1..-1, 0..0] - assert_equal [[list[0], [[8, 20]]]], matcher.match(list, 'sr', '', '') - assert_equal [[list[1], [[0, 2]]]], matcher.match(list, 'fo', '', '') - - matcher = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE, :fuzzy, [0..0, 1..2] - assert_equal [], matcher.match(list, '^t', '', '') - - matcher = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE, :fuzzy, [0..1, 2..2] - assert_equal [[list[0], [[16, 17]]]], matcher.match(list, '^t', '', '') - - matcher = FZF::ExtendedFuzzyMatcher.new Regexp::IGNORECASE, :fuzzy, [1..-1] - assert_equal [[list[0], [[8, 9]]]], matcher.match(list, '^s', '', '') - end - - def stream_for str, delay = 0 - StringIO.new(str).tap do |sio| - sio.instance_eval do - alias org_gets gets - - def gets - org_gets.tap { |e| sleep(@delay) unless e.nil? } - end - - def reopen _ - end - end - sio.instance_variable_set :@delay, delay - end - end - - def assert_fzf_output opts, given, expected - stream = stream_for given - output = stream_for '' - - def sorted_lines line - line.split($/).sort - end - - begin - tty = MockTTY.new - $stdout = output - fzf = FZF.new(opts, stream) - fzf.instance_variable_set :@tty, tty - thr = block_given? && Thread.new { yield tty } - fzf.start - thr && thr.join - rescue SystemExit => e - assert_equal 0, e.status - assert_equal sorted_lines(expected), sorted_lines(output.string) - ensure - $stdout = STDOUT - end - end - - def test_filter - { - %w[--filter=ol] => 'World', - %w[--filter=ol --print-query] => "ol\nWorld", - }.each do |opts, expected| - assert_fzf_output opts, "Hello\nWorld", expected - end - end - - def test_select_1 - { - %w[--query=ol --select-1] => 'World', - %w[--query=ol --select-1 --print-query] => "ol\nWorld", - }.each do |opts, expected| - assert_fzf_output opts, "Hello\nWorld", expected - end - end - - def test_select_1_without_query - assert_fzf_output %w[--select-1], 'Hello World', 'Hello World' - end - - def test_select_1_ambiguity - begin - Timeout::timeout(0.5) do - assert_fzf_output %w[--query=o --select-1], "hello\nworld", "should not match" - end - rescue Timeout::Error - Curses.close_screen - end - end - - def test_exit_0 - { - %w[--query=zz --exit-0] => '', - %w[--query=zz --exit-0 --print-query] => 'zz', - }.each do |opts, expected| - assert_fzf_output opts, "Hello\nWorld", expected - end - end - - def test_exit_0_without_query - assert_fzf_output %w[--exit-0], '', '' - end - - def test_with_nth - source = "hello world\nbatman" - assert_fzf_output %w[-0 -1 --with-nth=2,1 -x -q ^worl], - source, 'hello world' - assert_fzf_output %w[-0 -1 --with-nth=2,1 -x -q llo$], - source, 'hello world' - assert_fzf_output %w[-0 -1 --with-nth=.. -x -q llo$], - source, '' - assert_fzf_output %w[-0 -1 --with-nth=2,2,2,..,1 -x -q worlworlworlhellworlhell], - source, 'hello world' - assert_fzf_output %w[-0 -1 --with-nth=1,1,-1,1 -x -q batbatbatbat], - source, 'batman' - end - - def test_with_nth_transform - fzf = FZF.new %w[--with-nth 2..,1] - assert_equal 'my world hello', fzf.transform('hello my world') - assert_equal 'my world hello', fzf.transform('hello my world') - assert_equal 'my world hello', fzf.transform('hello my world ') - - fzf = FZF.new %w[--with-nth 2,-1,2] - assert_equal 'my world my', fzf.transform('hello my world') - assert_equal 'world world world', fzf.transform('hello world') - assert_equal 'world world world', fzf.transform('hello world ') - end - - def test_ranking_overlap_match_regions - list = [ - '1 3 4 2', - '1 2 3 4' - ] - assert_equal [ - ['1 2 3 4', [[0, 13], [16, 22]]], - ['1 3 4 2', [[0, 24], [12, 17]]], - ], FZF.sort(FZF::ExtendedFuzzyMatcher.new(nil).match(list, '12 34', '', '')) - end - - def test_constrain - fzf = FZF.new [] - - # [#**** ] - assert_equal [false, 0, 0], fzf.constrain(0, 0, 5, 100) - - # *****[**#** ... ] => [**#******* ... ] - assert_equal [true, 0, 2], fzf.constrain(5, 7, 10, 100) - - # [**********]**#** => ***[*********#]** - assert_equal [true, 3, 12], fzf.constrain(0, 12, 15, 10) - - # *****[**#** ] => ***[**#****] - assert_equal [true, 3, 5], fzf.constrain(5, 7, 10, 7) - - # *****[**#** ] => ****[**#***] - assert_equal [true, 4, 6], fzf.constrain(5, 7, 10, 6) - - # ***** [#] => ****[#] - assert_equal [true, 4, 4], fzf.constrain(10, 10, 5, 1) - - # [ ] #**** => [#]**** - assert_equal [true, 0, 0], fzf.constrain(-5, 0, 5, 1) - - # [ ] **#** => **[#]** - assert_equal [true, 2, 2], fzf.constrain(-5, 2, 5, 1) - - # [***** #] => [****# ] - assert_equal [true, 0, 4], fzf.constrain(0, 7, 5, 10) - - # **[***** #] => [******# ] - assert_equal [true, 0, 6], fzf.constrain(2, 10, 7, 10) - end - - def test_invalid_utf8 - tmp = Tempfile.new('fzf') - tmp << 'hello ' << [0xff].pack('C*') << ' world' << $/ << [0xff].pack('C*') - tmp.close - begin - Timeout::timeout(0.5) do - FZF.new(%w[-n..,1,2.. -q^ -x], File.open(tmp.path)).start - end - rescue Timeout::Error - Curses.close_screen - end - ensure - tmp.unlink - end - - def test_with_nth_mock_tty - # Manual selection with input - assert_fzf_output ["--with-nth=2,1"], "hello world", "hello world" do |tty| - tty << "world" - tty << "hell" - tty << "\r" - end - - # Manual selection without input - assert_fzf_output ["--with-nth=2,1"], "hello world", "hello world" do |tty| - tty << "\r" - end - - # Manual selection with input and --multi - lines = "hello world\ngoodbye world" - assert_fzf_output %w[-m --with-nth=2,1], lines, lines do |tty| - tty << "o" - tty << "\e[Z\e[Z" - tty << "\r" - end - - # Manual selection without input and --multi - assert_fzf_output %w[-m --with-nth=2,1], lines, lines do |tty| - tty << "\e[Z\e[Z" - tty << "\r" - end - - # ALT-D - assert_fzf_output %w[--print-query], "", "hello baby = world" do |tty| - tty << "hello world baby" - tty << alt(:b) << alt(:b) << alt(:d) - tty << ctrl(:e) << " = " << ctrl(:y) - tty << "\r" - end - - # ALT-BACKSPACE - assert_fzf_output %w[--print-query], "", "hello baby = world " do |tty| - tty << "hello world baby" - tty << alt(:b) << alt(127.chr) - tty << ctrl(:e) << " = " << ctrl(:y) - tty << "\r" - end - - # Word-movements - assert_fzf_output %w[--print-query], "", "ello!_orld!~ foo=?" do |tty| - tty << "hello_world==baby?" - tty << alt(:b) << ctrl(:d) - tty << alt(:b) << ctrl(:d) - tty << alt(:b) << ctrl(:d) - tty << alt(:f) << '!' - tty << alt(:f) << '!' - tty << alt(:d) << '~' - tty << " foo=bar foo=bar" - tty << ctrl(:w) - tty << alt(127.chr) - tty << "\r" - end - end - - def alt chr - "\e#{chr}" - end - - def ctrl char - char.to_s.ord - 'a'.ord + 1 - end -end - diff --git a/test/test_go.rb b/test/test_go.rb new file mode 100644 index 0000000..e76b520 --- /dev/null +++ b/test/test_go.rb @@ -0,0 +1,1100 @@ +#!/usr/bin/env ruby +# encoding: utf-8 + +require 'minitest/autorun' +require 'fileutils' + +DEFAULT_TIMEOUT = 20 + +base = File.expand_path('../../', __FILE__) +Dir.chdir base +FZF = "#{base}/bin/fzf" + +class NilClass + def include? str + false + end + + def start_with? str + false + end + + def end_with? str + false + end +end + +def wait + since = Time.now + while Time.now - since < DEFAULT_TIMEOUT + return if yield + sleep 0.05 + end + throw 'timeout' +end + +class Shell + class << self + def bash + 'PS1= PROMPT_COMMAND= bash --rcfile ~/.fzf.bash' + end + + def zsh + FileUtils.mkdir_p '/tmp/fzf-zsh' + FileUtils.cp File.expand_path('~/.fzf.zsh'), '/tmp/fzf-zsh/.zshrc' + 'PS1= PROMPT_COMMAND= HISTSIZE=100 ZDOTDIR=/tmp/fzf-zsh zsh' + end + end +end + +class Tmux + TEMPNAME = '/tmp/fzf-test.txt' + + attr_reader :win + + def initialize shell = :bash + @win = + case shell + when :bash + go("new-window -d -P -F '#I' '#{Shell.bash}'").first + when :zsh + go("new-window -d -P -F '#I' '#{Shell.zsh}'").first + when :fish + go("new-window -d -P -F '#I' 'fish'").first + else + raise "Unknown shell: #{shell}" + end + @lines = `tput lines`.chomp.to_i + + if shell == :fish + send_keys('function fish_prompt; end; clear', :Enter) + self.until { |lines| lines.empty? } + end + end + + def kill + go("kill-window -t #{win} 2> /dev/null") + end + + def send_keys *args + target = + if args.last.is_a?(Hash) + hash = args.pop + go("select-window -t #{win}") + "#{win}.#{hash[:pane]}" + else + win + end + args = args.map { |a| %{"#{a}"} }.join ' ' + go("send-keys -t #{target} #{args}") + end + + def capture pane = 0 + File.unlink TEMPNAME while File.exists? TEMPNAME + wait do + go("capture-pane -t #{win}.#{pane} \\; save-buffer #{TEMPNAME} 2> /dev/null") + $?.exitstatus == 0 + end + File.read(TEMPNAME).split($/)[0, @lines].reverse.drop_while(&:empty?).reverse + end + + def until pane = 0 + lines = nil + begin + wait do + lines = capture(pane) + class << lines + def item_count + self[-2] ? self[-2].strip.split('/').last.to_i : 0 + end + end + yield lines + end + rescue Exception + puts $!.backtrace + puts '>' * 80 + puts lines + puts '<' * 80 + raise + end + lines + end + + def prepare + tries = 0 + begin + self.send_keys 'C-u', 'hello', 'Right' + self.until { |lines| lines[-1].end_with?('hello') } + rescue Exception + (tries += 1) < 5 ? retry : raise + end + self.send_keys 'C-u' + end +private + def go *args + %x[tmux #{args.join ' '}].split($/) + end +end + +class TestBase < Minitest::Test + TEMPNAME = '/tmp/output' + + attr_reader :tmux + + def tempname + [TEMPNAME, + caller_locations.map(&:label).find { |l| l =~ /^test_/ }].join '-' + end + + def setup + ENV.delete 'FZF_DEFAULT_OPTS' + ENV.delete 'FZF_CTRL_T_COMMAND' + ENV.delete 'FZF_DEFAULT_COMMAND' + end + + def readonce + wait { File.exists?(tempname) } + File.read(tempname) + ensure + File.unlink tempname while File.exists?(tempname) + tmux.prepare + end + + def fzf(*opts) + fzf!(*opts) + " > #{tempname}.tmp; mv #{tempname}.tmp #{tempname}" + end + + def fzf!(*opts) + opts = opts.map { |o| + case o + when Symbol + o = o.to_s + o.length > 1 ? "--#{o.gsub('_', '-')}" : "-#{o}" + when String, Numeric + o.to_s + else + nil + end + }.compact + "#{FZF} #{opts.join ' '}" + end +end + +class TestGoFZF < TestBase + def setup + super + @tmux = Tmux.new + end + + def teardown + @tmux.kill + end + + def test_vanilla + tmux.send_keys "seq 1 100000 | #{fzf}", :Enter + tmux.until { |lines| lines.last =~ /^>/ && lines[-2] =~ /^ 100000/ } + lines = tmux.capture + assert_equal ' 2', lines[-4] + assert_equal '> 1', lines[-3] + assert_equal ' 100000/100000', lines[-2] + assert_equal '>', lines[-1] + + # Testing basic key bindings + tmux.send_keys '99', 'C-a', '1', 'C-f', '3', 'C-b', 'C-h', 'C-u', 'C-e', 'C-y', 'C-k', 'Tab', 'BTab' + tmux.until { |lines| lines[-2] == ' 856/100000' } + lines = tmux.capture + assert_equal '> 1391', lines[-4] + assert_equal ' 391', lines[-3] + assert_equal ' 856/100000', lines[-2] + assert_equal '> 391', lines[-1] + + tmux.send_keys :Enter + assert_equal '1391', readonce.chomp + end + + def test_fzf_default_command + tmux.send_keys "FZF_DEFAULT_COMMAND='echo hello' #{fzf}", :Enter + tmux.until { |lines| lines.last =~ /^>/ } + + tmux.send_keys :Enter + assert_equal 'hello', readonce.chomp + end + + def test_key_bindings + tmux.send_keys "#{FZF} -q 'foo bar foo-bar'", :Enter + tmux.until { |lines| lines.last =~ /^>/ } + + # CTRL-A + tmux.send_keys "C-A", "(" + tmux.until { |lines| lines.last == '> (foo bar foo-bar' } + + # META-F + tmux.send_keys :Escape, :f, ")" + tmux.until { |lines| lines.last == '> (foo) bar foo-bar' } + + # CTRL-B + tmux.send_keys "C-B", "var" + tmux.until { |lines| lines.last == '> (foovar) bar foo-bar' } + + # Left, CTRL-D + tmux.send_keys :Left, :Left, "C-D" + tmux.until { |lines| lines.last == '> (foovr) bar foo-bar' } + + # META-BS + tmux.send_keys :Escape, :BSpace + tmux.until { |lines| lines.last == '> (r) bar foo-bar' } + + # CTRL-Y + tmux.send_keys "C-Y", "C-Y" + tmux.until { |lines| lines.last == '> (foovfoovr) bar foo-bar' } + + # META-B + tmux.send_keys :Escape, :b, :Space, :Space + tmux.until { |lines| lines.last == '> ( foovfoovr) bar foo-bar' } + + # CTRL-F / Right + tmux.send_keys 'C-F', :Right, '/' + tmux.until { |lines| lines.last == '> ( fo/ovfoovr) bar foo-bar' } + + # CTRL-H / BS + tmux.send_keys 'C-H', :BSpace + tmux.until { |lines| lines.last == '> ( fovfoovr) bar foo-bar' } + + # CTRL-E + tmux.send_keys "C-E", 'baz' + tmux.until { |lines| lines.last == '> ( fovfoovr) bar foo-barbaz' } + + # CTRL-U + tmux.send_keys "C-U" + tmux.until { |lines| lines.last == '>' } + + # CTRL-Y + tmux.send_keys "C-Y" + tmux.until { |lines| lines.last == '> ( fovfoovr) bar foo-barbaz' } + + # CTRL-W + tmux.send_keys "C-W", "bar-foo" + tmux.until { |lines| lines.last == '> ( fovfoovr) bar bar-foo' } + + # META-D + tmux.send_keys :Escape, :b, :Escape, :b, :Escape, :d, "C-A", "C-Y" + tmux.until { |lines| lines.last == '> bar( fovfoovr) bar -foo' } + + # CTRL-M + tmux.send_keys "C-M" + tmux.until { |lines| lines.last !~ /^>/ } + end + + def test_multi_order + tmux.send_keys "seq 1 10 | #{fzf :multi}", :Enter + tmux.until { |lines| lines.last =~ /^>/ } + + tmux.send_keys :Tab, :Up, :Up, :Tab, :Tab, :Tab, # 3, 2 + 'C-K', 'C-K', 'C-K', 'C-K', :BTab, :BTab, # 5, 6 + :PgUp, 'C-J', :Down, :Tab, :Tab # 8, 7 + tmux.until { |lines| lines[-2].include? '(6)' } + tmux.send_keys "C-M" + assert_equal %w[3 2 5 6 8 7], readonce.split($/) + end + + def test_with_nth + [true, false].each do |multi| + tmux.send_keys "(echo ' 1st 2nd 3rd/'; + echo ' first second third/') | + #{fzf multi && :multi, :x, :nth, 2, :with_nth, '2,-1,1'}", + :Enter + tmux.until { |lines| lines[-2].include?('2/2') } + + # Transformed list + lines = tmux.capture + assert_equal ' second third/first', lines[-4] + assert_equal '> 2nd 3rd/1st', lines[-3] + + # However, the output must not be transformed + if multi + tmux.send_keys :BTab, :BTab + tmux.until { |lines| lines[-2].include?('(2)') } + tmux.send_keys :Enter + assert_equal [' 1st 2nd 3rd/', ' first second third/'], readonce.split($/) + else + tmux.send_keys '^', '3' + tmux.until { |lines| lines[-2].include?('1/2') } + tmux.send_keys :Enter + assert_equal [' 1st 2nd 3rd/'], readonce.split($/) + end + end + end + + def test_scroll + [true, false].each do |rev| + tmux.send_keys "seq 1 100 | #{fzf rev && :reverse}", :Enter + tmux.until { |lines| lines.include? ' 100/100' } + tmux.send_keys *110.times.map { rev ? :Down : :Up } + tmux.until { |lines| lines.include? '> 100' } + tmux.send_keys :Enter + assert_equal '100', readonce.chomp + end + end + + def test_select_1 + tmux.send_keys "seq 1 100 | #{fzf :with_nth, '..,..', :print_query, :q, 5555, :'1'}", :Enter + assert_equal ['5555', '55'], readonce.split($/) + end + + def test_exit_0 + tmux.send_keys "seq 1 100 | #{fzf :with_nth, '..,..', :print_query, :q, 555555, :'0'}", :Enter + assert_equal ['555555'], readonce.split($/) + end + + def test_select_1_exit_0_fail + [:'0', :'1', [:'1', :'0']].each do |opt| + tmux.send_keys "seq 1 100 | #{fzf :print_query, :multi, :q, 5, *opt}", :Enter + tmux.until { |lines| lines.last =~ /^> 5/ } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| lines[-2].include?('(3)') } + tmux.send_keys :Enter + assert_equal ['5', '5', '15', '25'], readonce.split($/) + end + end + + def test_query_unicode + tmux.send_keys "(echo abc; echo 가나다) | #{fzf :query, '가다'}", :Enter + tmux.until { |lines| lines[-2].include? '1/2' } + tmux.send_keys :Enter + assert_equal ['가나다'], readonce.split($/) + end + + def test_sync + tmux.send_keys "seq 1 100 | #{fzf! :multi} | awk '{print \\$1 \\$1}' | #{fzf :sync}", :Enter + tmux.until { |lines| lines[-1] == '>' } + tmux.send_keys 9 + tmux.until { |lines| lines[-2] == ' 19/100' } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| lines[-2].include?('(3)') } + tmux.send_keys :Enter + tmux.until { |lines| lines[-1] == '>' } + tmux.send_keys 'C-K', :Enter + assert_equal ['1919'], readonce.split($/) + end + + def test_tac + tmux.send_keys "seq 1 1000 | #{fzf :tac, :multi}", :Enter + tmux.until { |lines| lines[-2].include? '1000/1000' } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| lines[-2].include?('(3)') } + tmux.send_keys :Enter + assert_equal %w[1000 999 998], readonce.split($/) + end + + def test_tac_sort + tmux.send_keys "seq 1 1000 | #{fzf :tac, :multi}", :Enter + tmux.until { |lines| lines[-2].include? '1000/1000' } + tmux.send_keys '99' + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| lines[-2].include?('(3)') } + tmux.send_keys :Enter + assert_equal %w[99 999 998], readonce.split($/) + end + + def test_tac_nosort + tmux.send_keys "seq 1 1000 | #{fzf :tac, :no_sort, :multi}", :Enter + tmux.until { |lines| lines[-2].include? '1000/1000' } + tmux.send_keys '00' + tmux.until { |lines| lines[-2].include? '10/1000' } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| lines[-2].include?('(3)') } + tmux.send_keys :Enter + assert_equal %w[1000 900 800], readonce.split($/) + end + + def test_expect + test = lambda do |key, feed, expected = key| + tmux.send_keys "seq 1 100 | #{fzf :expect, key}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys '55' + tmux.until { |lines| lines[-2].include? '1/100' } + tmux.send_keys *feed + assert_equal [expected, '55'], readonce.split($/) + end + test.call 'ctrl-t', 'C-T' + test.call 'ctrl-t', 'Enter', '' + test.call 'alt-c', [:Escape, :c] + test.call 'f1', 'f1' + test.call 'f2', 'f2' + test.call 'f3', 'f3' + test.call 'f2,f4', 'f2', 'f2' + test.call 'f2,f4', 'f4', 'f4' + test.call '@', '@' + end + + def test_expect_print_query + tmux.send_keys "seq 1 100 | #{fzf '--expect=alt-z', :print_query}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys '55' + tmux.until { |lines| lines[-2].include? '1/100' } + tmux.send_keys :Escape, :z + assert_equal ['55', 'alt-z', '55'], readonce.split($/) + end + + def test_expect_print_query_select_1 + tmux.send_keys "seq 1 100 | #{fzf '-q55 -1 --expect=alt-z --print-query'}", :Enter + assert_equal ['55', '', '55'], readonce.split($/) + end + + def test_toggle_sort + ['--toggle-sort=ctrl-r', '--bind=ctrl-r:toggle-sort'].each do |opt| + tmux.send_keys "seq 1 111 | #{fzf "-m +s --tac #{opt} -q11"}", :Enter + tmux.until { |lines| lines[-3].include? '> 111' } + tmux.send_keys :Tab + tmux.until { |lines| lines[-2].include? '4/111 (1)' } + tmux.send_keys 'C-R' + tmux.until { |lines| lines[-3].include? '> 11' } + tmux.send_keys :Tab + tmux.until { |lines| lines[-2].include? '4/111/S (2)' } + tmux.send_keys :Enter + assert_equal ['111', '11'], readonce.split($/) + end + end + + def test_unicode_case + writelines tempname, %w[строКА1 СТРОКА2 строка3 Строка4] + assert_equal %w[СТРОКА2 Строка4], `cat #{tempname} | #{FZF} -fС`.split($/) + assert_equal %w[строКА1 СТРОКА2 строка3 Строка4], `cat #{tempname} | #{FZF} -fс`.split($/) + end + + def test_tiebreak + input = %w[ + --foobar-------- + -----foobar--- + ----foobar-- + -------foobar- + ] + writelines tempname, input + + assert_equal input, `cat #{tempname} | #{FZF} -ffoobar --tiebreak=index`.split($/) + + by_length = %w[ + ----foobar-- + -----foobar--- + -------foobar- + --foobar-------- + ] + assert_equal by_length, `cat #{tempname} | #{FZF} -ffoobar`.split($/) + assert_equal by_length, `cat #{tempname} | #{FZF} -ffoobar --tiebreak=length`.split($/) + + by_begin = %w[ + --foobar-------- + ----foobar-- + -----foobar--- + -------foobar- + ] + assert_equal by_begin, `cat #{tempname} | #{FZF} -ffoobar --tiebreak=begin`.split($/) + assert_equal by_begin, `cat #{tempname} | #{FZF} -f"!z foobar" -x --tiebreak begin`.split($/) + + assert_equal %w[ + -------foobar- + ----foobar-- + -----foobar--- + --foobar-------- + ], `cat #{tempname} | #{FZF} -ffoobar --tiebreak end`.split($/) + + assert_equal input, `cat #{tempname} | #{FZF} -f"!z" -x --tiebreak end`.split($/) + end + + def test_tiebreak_length_with_nth + input = %w[ + 1:hell + 123:hello + 12345:he + 1234567:h + ] + writelines tempname, input + + output = %w[ + 1:hell + 12345:he + 123:hello + 1234567:h + ] + assert_equal output, `cat #{tempname} | #{FZF} -fh`.split($/) + + output = %w[ + 1234567:h + 12345:he + 1:hell + 123:hello + ] + assert_equal output, `cat #{tempname} | #{FZF} -fh -n2 -d:`.split($/) + end + + def test_tiebreak_end_backward_scan + input = %w[ + foobar-fb + fubar + ] + writelines tempname, input + + assert_equal input.reverse, `cat #{tempname} | #{FZF} -f fb`.split($/) + assert_equal input, `cat #{tempname} | #{FZF} -f fb --tiebreak=end`.split($/) + end + + def test_invalid_cache + tmux.send_keys "(echo d; echo D; echo x) | #{fzf '-q d'}", :Enter + tmux.until { |lines| lines[-2].include? '2/3' } + tmux.send_keys :BSpace + tmux.until { |lines| lines[-2].include? '3/3' } + tmux.send_keys :D + tmux.until { |lines| lines[-2].include? '1/3' } + tmux.send_keys :Enter + end + + def test_smart_case_for_each_term + assert_equal 1, `echo Foo bar | #{FZF} -x -f "foo Fbar" | wc -l`.to_i + end + + def test_bind + tmux.send_keys "seq 1 1000 | #{ + fzf '-m --bind=ctrl-j:accept,u:up,T:toggle-up,t:toggle'}", :Enter + tmux.until { |lines| lines[-2].end_with? '/1000' } + tmux.send_keys 'uuu', 'TTT', 'tt', 'uu', 'ttt', 'C-j' + assert_equal %w[4 5 6 9], readonce.split($/) + end + + def test_long_line + data = '.' * 256 * 1024 + File.open(tempname, 'w') do |f| + f << data + end + assert_equal data, `cat #{tempname} | #{FZF} -f .`.chomp + end + + def test_read0 + lines = `find .`.split($/) + assert_equal lines.last, `find . | #{FZF} -e -f "^#{lines.last}$"`.chomp + assert_equal lines.last, `find . -print0 | #{FZF} --read0 -e -f "^#{lines.last}$"`.chomp + end + + def test_select_all_deselect_all_toggle_all + tmux.send_keys "seq 100 | #{fzf '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi'}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| lines[-2].include? '(3)' } + tmux.send_keys 'C-t' + tmux.until { |lines| lines[-2].include? '(97)' } + tmux.send_keys 'C-a' + tmux.until { |lines| lines[-2].include? '(100)' } + tmux.send_keys :Tab, :Tab + tmux.until { |lines| lines[-2].include? '(98)' } + tmux.send_keys 'C-d' + tmux.until { |lines| !lines[-2].include? '(' } + tmux.send_keys :Tab, :Tab + tmux.until { |lines| lines[-2].include? '(2)' } + tmux.send_keys 0 + tmux.until { |lines| lines[-2].include? '10/100' } + tmux.send_keys 'C-a' + tmux.until { |lines| lines[-2].include? '(12)' } + tmux.send_keys :Enter + assert_equal %w[2 1 10 20 30 40 50 60 70 80 90 100], readonce.split($/) + end + + def test_history + history_file = '/tmp/fzf-test-history' + + # History with limited number of entries + File.unlink history_file rescue nil + opts = "--history=#{history_file} --history-size=4" + input = %w[00 11 22 33 44].map { |e| e + $/ } + input.each do |keys| + tmux.send_keys "seq 100 | #{fzf opts}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys keys + tmux.until { |lines| lines[-2].include? '1/100' } + tmux.send_keys :Enter + readonce + end + assert_equal input[1..-1], File.readlines(history_file) + + # Update history entries (not changed on disk) + tmux.send_keys "seq 100 | #{fzf opts}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys 'C-p' + tmux.until { |lines| lines[-1].end_with? '> 44' } + tmux.send_keys 'C-p' + tmux.until { |lines| lines[-1].end_with? '> 33' } + tmux.send_keys :BSpace + tmux.until { |lines| lines[-1].end_with? '> 3' } + tmux.send_keys 1 + tmux.until { |lines| lines[-1].end_with? '> 31' } + tmux.send_keys 'C-p' + tmux.until { |lines| lines[-1].end_with? '> 22' } + tmux.send_keys 'C-n' + tmux.until { |lines| lines[-1].end_with? '> 31' } + tmux.send_keys 0 + tmux.until { |lines| lines[-1].end_with? '> 310' } + tmux.send_keys :Enter + readonce + assert_equal %w[22 33 44 310].map { |e| e + $/ }, File.readlines(history_file) + + # Respect --bind option + tmux.send_keys "seq 100 | #{fzf opts + ' --bind ctrl-p:next-history,ctrl-n:previous-history'}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys 'C-n', 'C-n', 'C-n', 'C-n', 'C-p' + tmux.until { |lines| lines[-1].end_with?('33') } + tmux.send_keys :Enter + ensure + File.unlink history_file + end + + def test_execute + output = '/tmp/fzf-test-execute' + opts = %[--bind \\"alt-a:execute(echo '[{}]' >> #{output}),alt-b:execute[echo '({}), ({})' >> #{output}],C:execute:echo '({}), [{}], @{}@' >> #{output}\\"] + tmux.send_keys "seq 100 | #{fzf opts}", :Enter + tmux.until { |lines| lines[-2].include? '100/100' } + tmux.send_keys :Escape, :a, :Escape, :a + tmux.send_keys :Up + tmux.send_keys :Escape, :b, :Escape, :b + tmux.send_keys :Up + tmux.send_keys :C + tmux.send_keys 'foobar' + tmux.until { |lines| lines[-2].include? '0/100' } + tmux.send_keys :Escape, :a, :Escape, :b, :Escape, :c + tmux.send_keys :Enter + readonce + assert_equal ['["1"]', '["1"]', '("2"), ("2")', '("2"), ("2")', '("3"), ["3"], @"3"@'], + File.readlines(output).map(&:chomp) + ensure + File.unlink output rescue nil + end + + def test_cycle + tmux.send_keys "seq 8 | #{fzf :cycle}", :Enter + tmux.until { |lines| lines[-2].include? '8/8' } + tmux.send_keys :Down + tmux.until { |lines| lines[-10].start_with? '>' } + tmux.send_keys :Down + tmux.until { |lines| lines[-9].start_with? '>' } + tmux.send_keys :PgUp + tmux.until { |lines| lines[-10].start_with? '>' } + tmux.send_keys :PgUp + tmux.until { |lines| lines[-3].start_with? '>' } + tmux.send_keys :Up + tmux.until { |lines| lines[-4].start_with? '>' } + tmux.send_keys :PgDn + tmux.until { |lines| lines[-3].start_with? '>' } + tmux.send_keys :PgDn + tmux.until { |lines| lines[-10].start_with? '>' } + end + + def test_header_lines + tmux.send_keys "seq 100 | #{fzf '--header-lines=10 -q 5'}", :Enter + 2.times do + tmux.until do |lines| + lines[-2].include?('/90') && + lines[-3] == ' 1' && + lines[-4] == ' 2' && + lines[-13] == '> 15' + end + tmux.send_keys :Down + end + tmux.send_keys :Enter + assert_equal '15', readonce.chomp + end + + def test_header_lines_reverse + tmux.send_keys "seq 100 | #{fzf '--header-lines=10 -q 5 --reverse'}", :Enter + 2.times do + tmux.until do |lines| + lines[1].include?('/90') && + lines[2] == ' 1' && + lines[3] == ' 2' && + lines[12] == '> 15' + end + tmux.send_keys :Up + end + tmux.send_keys :Enter + assert_equal '15', readonce.chomp + end + + def test_header_lines_overflow + tmux.send_keys "seq 100 | #{fzf '--header-lines=200'}", :Enter + tmux.until do |lines| + lines[-2].include?('0/0') && + lines[-3].include?(' 1') + end + tmux.send_keys :Enter + assert_equal '', readonce.chomp + end + + def test_header_lines_with_nth + tmux.send_keys "seq 100 | #{fzf "--header-lines 5 --with-nth 1,1,1,1,1"}", :Enter + tmux.until do |lines| + lines[-2].include?('95/95') && + lines[-3] == ' 11111' && + lines[-7] == ' 55555' && + lines[-8] == '> 66666' + end + tmux.send_keys :Enter + assert_equal '6', readonce.chomp + end + + def test_header + tmux.send_keys "seq 100 | #{fzf "--header \\\"\\$(head -5 #{__FILE__})\\\""}", :Enter + header = File.readlines(__FILE__).take(5).map(&:strip) + tmux.until do |lines| + lines[-2].include?('100/100') && + lines[-7..-3].map(&:strip) == header + end + end + + def test_header_reverse + tmux.send_keys "seq 100 | #{fzf "--header=\\\"\\$(head -5 #{__FILE__})\\\" --reverse"}", :Enter + header = File.readlines(__FILE__).take(5).map(&:strip) + tmux.until do |lines| + lines[1].include?('100/100') && + lines[2..6].map(&:strip) == header + end + end + + def test_header_and_header_lines + tmux.send_keys "seq 100 | #{fzf "--header-lines 10 --header \\\"\\$(head -5 #{__FILE__})\\\""}", :Enter + header = File.readlines(__FILE__).take(5).map(&:strip) + tmux.until do |lines| + lines[-2].include?('90/90') && + lines[-7...-2].map(&:strip) == header && + lines[-17...-7].map(&:strip) == (1..10).map(&:to_s).reverse + end + end + + def test_header_and_header_lines_reverse + tmux.send_keys "seq 100 | #{fzf "--reverse --header-lines 10 --header \\\"\\$(head -5 #{__FILE__})\\\""}", :Enter + header = File.readlines(__FILE__).take(5).map(&:strip) + tmux.until do |lines| + lines[1].include?('90/90') && + lines[2...7].map(&:strip) == header && + lines[7...17].map(&:strip) == (1..10).map(&:to_s) + end + end + + def test_canel + tmux.send_keys "seq 10 | #{fzf "--bind 2:cancel"}", :Enter + tmux.until { |lines| lines[-2].include?('10/10') } + tmux.send_keys '123' + tmux.until { |lines| lines[-1] == '> 3' && lines[-2].include?('1/10') } + tmux.send_keys 'C-y', 'C-y' + tmux.until { |lines| lines[-1] == '> 311' } + tmux.send_keys 2 + tmux.until { |lines| lines[-1] == '>' } + tmux.send_keys 2 + tmux.prepare + end + + def test_margin + tmux.send_keys "yes | head -1000 | #{fzf "--margin 5,3"}", :Enter + tmux.until { |lines| lines[4] == '' && lines[5] == ' y' } + tmux.send_keys :Enter + end + + def test_margin_reverse + tmux.send_keys "seq 1000 | #{fzf "--margin 7,5 --reverse"}", :Enter + tmux.until { |lines| lines[1 + 7] == ' 1000/1000' } + tmux.send_keys :Enter + end + + def test_with_nth + writelines tempname, ['hello world ', 'byebye'] + assert_equal 'hello world ', `cat #{tempname} | #{FZF} -f"^he hehe" -x -n 2.. --with-nth 2,1,1`.chomp + end + + def test_with_nth_ansi + writelines tempname, ["\x1b[33mhello \x1b[34;1mworld\x1b[m ", 'byebye'] + assert_equal 'hello world ', `cat #{tempname} | #{FZF} -f"^he hehe" -x -n 2.. --with-nth 2,1,1 --ansi`.chomp + end + + def test_with_nth_no_ansi + src = "\x1b[33mhello \x1b[34;1mworld\x1b[m " + writelines tempname, [src, 'byebye'] + assert_equal src, `cat #{tempname} | #{FZF} -fhehe -x -n 2.. --with-nth 2,1,1 --no-ansi`.chomp + end + + def test_exit_0_exit_code + `echo foo | #{FZF} -q bar -0` + assert_equal 1, $?.exitstatus + end + + def test_invalid_term + lines = `TERM=xxx #{FZF}` + assert_equal 2, $?.exitstatus + assert lines.include?('Invalid $TERM: xxx') + end + + def test_invalid_option + lines = `#{FZF} --foobar 2>&1` + assert_equal 2, $?.exitstatus + assert lines.include?('unknown option: --foobar'), lines + end + + def test_filter_exitstatus + # filter / streaming filter + ["", "--no-sort"].each do |opts| + assert `echo foo | #{FZF} -f foo #{opts}`.include?('foo') + assert_equal 0, $?.exitstatus + + assert `echo foo | #{FZF} -f bar #{opts}`.empty? + assert_equal 1, $?.exitstatus + end + end + + def test_exitstatus_empty + { '99' => '0', '999' => '1' }.each do |query, status| + tmux.send_keys "seq 100 | #{FZF} -q #{query}", :Enter + tmux.until { |lines| lines[-2] =~ %r{ [10]/100} } + tmux.send_keys :Enter + + tmux.send_keys 'echo --\$?--' + tmux.until { |lines| lines.last.include? "echo --$?--" } + tmux.send_keys :Enter + tmux.until { |lines| lines.last.include? "--#{status}--" } + end + end + +private + def writelines path, lines + File.unlink path while File.exists? path + File.open(path, 'w') { |f| f << lines.join($/) } + end +end + +module TestShell + def setup + super + end + + def teardown + @tmux.kill + end + + def set_var name, val + tmux.prepare + tmux.send_keys "export #{name}='#{val}'", :Enter + tmux.prepare + end + + def test_ctrl_t + tmux.prepare + tmux.send_keys 'C-t', pane: 0 + lines = tmux.until(1) { |lines| lines.item_count > 1 } + expected = lines.values_at(-3, -4).map { |line| line[2..-1] }.join(' ') + tmux.send_keys :BTab, :BTab, pane: 1 + tmux.until(1) { |lines| lines[-2].include?('(2)') } + tmux.send_keys :Enter, pane: 1 + tmux.until(0) { |lines| lines[-1].include? expected } + tmux.send_keys 'C-c' + + # FZF_TMUX=0 + new_shell + tmux.send_keys 'C-t', pane: 0 + lines = tmux.until(0) { |lines| lines.item_count > 1 } + expected = lines.values_at(-3, -4).map { |line| line[2..-1] }.join(' ') + tmux.send_keys :BTab, :BTab, pane: 0 + tmux.until(0) { |lines| lines[-2].include?('(2)') } + tmux.send_keys :Enter, pane: 0 + tmux.until(0) { |lines| lines[-1].include? expected } + tmux.send_keys 'C-c', 'C-d' + end + + def test_ctrl_t_command + set_var "FZF_CTRL_T_COMMAND", "seq 100" + tmux.send_keys 'C-t', pane: 0 + lines = tmux.until(1) { |lines| lines.item_count == 100 } + tmux.send_keys :BTab, :BTab, :BTab, pane: 1 + tmux.until(1) { |lines| lines[-2].include?('(3)') } + tmux.send_keys :Enter, pane: 1 + tmux.until(0) { |lines| lines[-1].include? '1 2 3' } + end + + def test_alt_c + tmux.prepare + tmux.send_keys :Escape, :c, pane: 0 + lines = tmux.until(1) { |lines| lines.item_count > 0 && lines[-3][2..-1] } + expected = lines[-3][2..-1] + tmux.send_keys :Enter, pane: 1 + tmux.prepare + tmux.send_keys :pwd, :Enter + tmux.until { |lines| lines[-1].end_with?(expected) } + end + + def test_ctrl_r + tmux.prepare + tmux.send_keys 'echo 1st', :Enter; tmux.prepare + tmux.send_keys 'echo 2nd', :Enter; tmux.prepare + tmux.send_keys 'echo 3d', :Enter; tmux.prepare + tmux.send_keys 'echo 3rd', :Enter; tmux.prepare + tmux.send_keys 'echo 4th', :Enter; tmux.prepare + tmux.send_keys 'C-r', pane: 0 + tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys '3d', pane: 1 + tmux.until(1) { |lines| lines[-3].end_with? 'echo 3rd' } # --no-sort + tmux.send_keys :Enter, pane: 1 + tmux.until { |lines| lines[-1] == 'echo 3rd' } + tmux.send_keys :Enter + tmux.until { |lines| lines[-1] == '3rd' } + end +end + +module CompletionTest + def test_file_completion + FileUtils.mkdir_p '/tmp/fzf-test' + FileUtils.mkdir_p '/tmp/fzf test' + (1..100).each { |i| FileUtils.touch "/tmp/fzf-test/#{i}" } + ['no~such~user', '/tmp/fzf test/foobar', '~/.fzf-home'].each do |f| + FileUtils.touch File.expand_path(f) + end + tmux.prepare + tmux.send_keys 'cat /tmp/fzf-test/10**', :Tab, pane: 0 + tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys :BTab, :BTab + tmux.until(1) { |lines| lines[-2].include?('(2)') } + tmux.send_keys :Enter + tmux.until do |lines| + tmux.send_keys 'C-L' + lines[-1].include?('/tmp/fzf-test/10') && + lines[-1].include?('/tmp/fzf-test/100') + end + + # ~USERNAME** + tmux.send_keys 'C-u' + tmux.send_keys "cat ~#{ENV['USER']}**", :Tab, pane: 0 + tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys '.fzf-home' + tmux.until(1) { |lines| lines[-3].end_with? '.fzf-home' } + tmux.send_keys :Enter + tmux.until do |lines| + tmux.send_keys 'C-L' + lines[-1].end_with?('.fzf-home') + end + + # ~INVALID_USERNAME** + tmux.send_keys 'C-u' + tmux.send_keys "cat ~such**", :Tab, pane: 0 + tmux.until(1) { |lines| lines[-3].end_with? 'no~such~user' } + tmux.send_keys :Enter + tmux.until do |lines| + tmux.send_keys 'C-L' + lines[-1].end_with?('no~such~user') + end + + # /tmp/fzf\ test** + tmux.send_keys 'C-u' + tmux.send_keys 'cat /tmp/fzf\ test/**', :Tab, pane: 0 + tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys :Enter + tmux.until do |lines| + tmux.send_keys 'C-L' + lines[-1].end_with?('/tmp/fzf\ test/foobar') + end + ensure + ['/tmp/fzf-test', '/tmp/fzf test', '~/.fzf-home', 'no~such~user'].each do |f| + FileUtils.rm_rf File.expand_path(f) + end + end + + def test_dir_completion + tmux.send_keys 'mkdir -p /tmp/fzf-test/d{1..100}; touch /tmp/fzf-test/d55/xxx', :Enter + tmux.prepare + tmux.send_keys 'cd /tmp/fzf-test/**', :Tab, pane: 0 + tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys :BTab, :BTab # BTab does not work here + tmux.send_keys 55 + tmux.until(1) { |lines| lines[-2].start_with? ' 1/' } + tmux.send_keys :Enter + tmux.until do |lines| + tmux.send_keys 'C-L' + lines[-1] == 'cd /tmp/fzf-test/d55/' + end + tmux.send_keys :xx + tmux.until { |lines| lines[-1] == 'cd /tmp/fzf-test/d55/xx' } + + # Should not match regular files (bash-only) + if self.class == TestBash + tmux.send_keys :Tab + tmux.until { |lines| lines[-1] == 'cd /tmp/fzf-test/d55/xx' } + end + + # Fail back to plusdirs + tmux.send_keys :BSpace, :BSpace, :BSpace + tmux.until { |lines| lines[-1] == 'cd /tmp/fzf-test/d55' } + tmux.send_keys :Tab + tmux.until { |lines| lines[-1] == 'cd /tmp/fzf-test/d55/' } + end + + def test_process_completion + tmux.send_keys 'sleep 12345 &', :Enter + lines = tmux.until { |lines| lines[-1].start_with? '[1]' } + pid = lines[-1].split.last + tmux.prepare + tmux.send_keys 'kill ', :Tab, pane: 0 + tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys 'sleep12345' + tmux.until(1) { |lines| lines[-3].include? 'sleep 12345' } + tmux.send_keys :Enter + tmux.until do |lines| + tmux.send_keys 'C-L' + lines[-1] == "kill #{pid}" + end + ensure + Process.kill 'KILL', pid.to_i rescue nil if pid + end +end + +class TestBash < TestBase + include TestShell + include CompletionTest + + def new_shell + tmux.prepare + tmux.send_keys "FZF_TMUX=0 #{Shell.bash}", :Enter + tmux.prepare + end + + def setup + super + @tmux = Tmux.new :bash + end +end + +class TestZsh < TestBase + include TestShell + include CompletionTest + + def new_shell + tmux.send_keys "FZF_TMUX=0 #{Shell.zsh}", :Enter + tmux.prepare + end + + def setup + super + @tmux = Tmux.new :zsh + end +end + +class TestFish < TestBase + include TestShell + + def new_shell + tmux.send_keys 'env FZF_TMUX=0 fish', :Enter + tmux.send_keys 'function fish_prompt; end; clear', :Enter + tmux.until { |lines| lines.empty? } + end + + def set_var name, val + tmux.prepare + tmux.send_keys "set -g #{name} '#{val}'", :Enter + tmux.prepare + end + + def setup + super + @tmux = Tmux.new :fish + end +end + diff --git a/uninstall b/uninstall index 1884c47..e2717a3 100755 --- a/uninstall +++ b/uninstall @@ -68,6 +68,7 @@ fi if [ -d ~/.config/fish/functions ]; then remove ~/.config/fish/functions/fzf.fish + remove ~/.config/fish/functions/fzf_key_bindings.fish if [ "$(ls -A ~/.config/fish/functions)" ]; then echo "Can't delete non-empty directory: \"~/.config/fish/functions\""