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/README.md b/README.md index 0b52864..998db0c 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,6 @@ fzf is a general-purpose fuzzy finder for your shell. It was heavily inspired by [ctrlp.vim](https://github.com/kien/ctrlp.vim) and the likes. -Requirements ------------- - -fzf requires Ruby (>= 1.8.5). - Installation ------------ @@ -436,21 +431,6 @@ If you have any rendering issues, check the followings: 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` @@ -545,12 +525,6 @@ function fe 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: diff --git a/install b/install index 3176b27..4098c87 100755 --- a/install +++ b/install @@ -1,74 +1,145 @@ #!/usr/bin/env bash -cd `dirname $BASH_SOURCE` -fzf_base=`pwd` +version=0.9.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 +ask() { + read -p "$1 ([y]/n) " -n 1 -r + echo + [[ ! $REPLY =~ ^[Nn]$ ]] +} -echo "OK ($ruby)" - -# 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 +check_binary() { + echo -n " - Checking fzf executable ... " + if ! "$fzf_base"/bin/fzf --version; then + rm -f "$fzf_base"/bin/fzf + binary_error="Error occurred" fi +} + +symlink() { + echo " - Creating symlink: bin/$1 -> bin/fzf" + rm -f "$fzf_base"/bin/fzf + ln -sf "$fzf_base"/bin/$1 "$fzf_base"/bin/fzf +} + +download() { + echo "Downloading bin/$1 ..." + if [ -x "$fzf_base"/bin/$1 ]; then + echo " - Already exists" + symlink $1 + check_binary && return + fi + mkdir -p "$fzf_base"/bin && cd "$fzf_base"/bin 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 + 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 && check_binary +} + +# 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 ;; +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 !!!" + fi + echo "Installing legacy Ruby version ..." + + # ruby executable + echo -n "Checking Ruby executable ... " + ruby=`which ruby` + if [ $? -ne 0 ]; then + echo "ruby executable not found !!!" 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 + # 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" fi # 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=$? echo @@ -81,7 +152,8 @@ for shell in bash zsh; do fzf_completion="# $fzf_completion" fi - cat > $src << EOF + if [ -n "$binary_error" ]; then + cat > $src << EOF # Setup fzf function # ------------------ unalias fzf 2> /dev/null @@ -95,6 +167,22 @@ export -f fzf > /dev/null $fzf_completion EOF + else + cat > $src << EOF +# Setup fzf +# --------- +unalias fzf 2> /dev/null +unset fzf 2> /dev/null +if [[ ! "\$PATH" =~ "$fzf_base/bin" ]]; then + export PATH="$fzf_base/bin:\$PATH" +fi + +# Auto-completion +# --------------- +$fzf_completion + +EOF + fi if [ $key_bindings -eq 0 ]; then if [ $shell = bash ]; then @@ -243,11 +331,19 @@ if [ -n "$(which fish)" ]; then has_fish=1 echo -n "Generate ~/.config/fish/functions/fzf.fish ... " mkdir -p ~/.config/fish/functions - cat > ~/.config/fish/functions/fzf.fish << EOFZF + if [ -n "$binary_error" ]; then + cat > ~/.config/fish/functions/fzf.fish << EOFZF function fzf $fzf_cmd \$argv end EOFZF + else + cat > ~/.config/fish/functions/fzf.fish << EOFZF +function fzf + $fzf_base/bin/fzf \$argv +end +EOFZF + fi echo "OK" if [ $key_bindings -eq 0 ]; then diff --git a/plugin/fzf.vim b/plugin/fzf.vim index db3c649..22fb4cc 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 " @@ -25,6 +25,7 @@ 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_go = expand(':h:h').'/bin/fzf' let s:fzf_rb = expand(':h:h').'/fzf' let s:cpo_save = &cpo @@ -34,7 +35,8 @@ 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 : '' + let s:exec = executable(s:fzf_go) ? + \ s:fzf_go : (executable(s:fzf_rb) ? s:fzf_rb : '') else let s:exec = 'fzf' endif diff --git a/src/Dockerfile.arch b/src/Dockerfile.arch new file mode 100644 index 0000000..054b95c --- /dev/null +++ b/src/Dockerfile.arch @@ -0,0 +1,27 @@ +FROM base/archlinux:2014.07.03 +MAINTAINER Junegunn Choi + +# apt-get +RUN pacman-db-upgrade && pacman -Syu --noconfirm base-devel git + +# Install Go 1.4 +RUN cd / && curl \ + https://storage.googleapis.com/golang/go1.4.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..5b27925 --- /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.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..91bf780 --- /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 + +# Install Go 1.4 +RUN cd / && curl \ + https://storage.googleapis.com/golang/go1.4.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..43a7bc0 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,73 @@ +ifndef GOPATH +$(error GOPATH is undefined) +endif + +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + GOOS := darwin +else ifeq ($(UNAME_S),Linux) + GOOS := linux +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 +BINARY64 := fzf-$(GOOS)_amd64 +RELEASE32 = fzf-$(shell fzf/$(BINARY64) --version)-$(GOOS)_386 +RELEASE64 = fzf-$(shell fzf/$(BINARY64) --version)-$(GOOS)_amd64 + +all: test release + +release: build + cd fzf && \ + cp $(BINARY32) $(RELEASE32) && tar -czf $(RELEASE32).tgz $(RELEASE32) && \ + cp $(BINARY64) $(RELEASE64) && tar -czf $(RELEASE64).tgz $(RELEASE64) && \ + rm $(RELEASE32) $(RELEASE64) + +build: fzf/$(BINARY32) fzf/$(BINARY64) + +test: + go get + go test -v ./... + +install: $(BINDIR)/fzf + +uninstall: + rm -f $(BINDIR)/fzf $(BINDIR)/$(BINARY64) + +clean: + cd fzf && rm -f $(BINARY32) $(BINARY64) $(RELEASE32).tgz $(RELEASE64).tgz + +fzf/$(BINARY32): $(SOURCES) + cd fzf && GOARCH=386 CGO_ENABLED=1 go build -o $(BINARY32) + +fzf/$(BINARY64): $(SOURCES) + cd fzf && go build -o $(BINARY64) + +$(BINDIR)/fzf: fzf/$(BINARY64) | $(BINDIR) + cp -f fzf/$(BINARY64) $(BINDIR) + cd $(BINDIR) && ln -sf $(BINARY64) fzf + +$(BINDIR): + mkdir -p $@ + +# Linux distribution to build fzf on +DISTRO := arch + +docker: + docker build -t junegunn/$(DISTRO)-sandbox - < Dockerfile.$(DISTRO) + +linux: docker + docker run -i -t -v $(GOPATH):/go junegunn/$(DISTRO)-sandbox \ + /bin/bash -ci 'cd /go/src/github.com/junegunn/fzf/src; make' + +$(DISTRO): docker + docker run -i -t -v $(GOPATH):/go junegunn/$(DISTRO)-sandbox \ + sh -c 'cd /go/src/github.com/junegunn/fzf/src; /bin/bash' + +.PHONY: all build release test install uninstall clean docker linux $(DISTRO) diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..c071865 --- /dev/null +++ b/src/README.md @@ -0,0 +1,115 @@ +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 +``` + +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) + +[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..bc4e538 --- /dev/null +++ b/src/algo/algo.go @@ -0,0 +1,155 @@ +package algo + +import "strings" + +/* + * 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. + */ + +// FuzzyMatch performs fuzzy-match +func FuzzyMatch(caseSensitive bool, input *string, pattern []rune) (int, int) { + runes := []rune(*input) + + // 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 + + for index, char := range runes { + // This is considerably faster than blindly applying strings.ToLower to the + // whole string + if !caseSensitive && char >= 65 && char <= 90 { + char += 32 + } + if char == pattern[pidx] { + if sidx < 0 { + sidx = index + } + if pidx++; pidx == len(pattern) { + eidx = index + 1 + break + } + } + } + + if sidx >= 0 && eidx >= 0 { + pidx-- + for index := eidx - 1; index >= sidx; index-- { + char := runes[index] + if !caseSensitive && char >= 65 && char <= 90 { + char += 32 + } + if char == pattern[pidx] { + if pidx--; pidx < 0 { + sidx = index + break + } + } + } + return sidx, eidx + } + return -1, -1 +} + +// ExactMatchStrings performs exact-match using strings package. +// Currently not used. +func ExactMatchStrings(caseSensitive bool, input *string, pattern []rune) (int, int) { + var str string + if caseSensitive { + str = *input + } else { + str = strings.ToLower(*input) + } + + if idx := strings.Index(str, string(pattern)); idx >= 0 { + prefixRuneLen := len([]rune((*input)[:idx])) + return prefixRuneLen, prefixRuneLen + len(pattern) + } + 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, input *string, pattern []rune) (int, int) { + runes := []rune(*input) + numRunes := len(runes) + plen := len(pattern) + if numRunes < plen { + return -1, -1 + } + + pidx := 0 + for index := 0; index < numRunes; index++ { + char := runes[index] + if !caseSensitive && char >= 65 && char <= 90 { + char += 32 + } + if pattern[pidx] == char { + pidx++ + if pidx == plen { + return index - plen + 1, index + 1 + } + } else { + index -= pidx + pidx = 0 + } + } + return -1, -1 +} + +// PrefixMatch performs prefix-match +func PrefixMatch(caseSensitive bool, input *string, pattern []rune) (int, int) { + runes := []rune(*input) + if len(runes) < len(pattern) { + return -1, -1 + } + + for index, r := range pattern { + char := runes[index] + if !caseSensitive && char >= 65 && char <= 90 { + char += 32 + } + if char != r { + return -1, -1 + } + } + return 0, len(pattern) +} + +// SuffixMatch performs suffix-match +func SuffixMatch(caseSensitive bool, input *string, pattern []rune) (int, int) { + runes := []rune(strings.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 >= 65 && char <= 90 { + char += 32 + } + if char != r { + return -1, -1 + } + } + return trimmedLen - len(pattern), trimmedLen +} diff --git a/src/algo/algo_test.go b/src/algo/algo_test.go new file mode 100644 index 0000000..363b6ee --- /dev/null +++ b/src/algo/algo_test.go @@ -0,0 +1,44 @@ +package algo + +import ( + "strings" + "testing" +) + +func assertMatch(t *testing.T, fun func(bool, *string, []rune) (int, int), caseSensitive bool, input string, pattern string, sidx int, eidx int) { + if !caseSensitive { + pattern = strings.ToLower(pattern) + } + s, e := fun(caseSensitive, &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, "fooBarbaz", "oBZ", 2, 9) + assertMatch(t, FuzzyMatch, true, "fooBarbaz", "oBZ", -1, -1) + assertMatch(t, FuzzyMatch, true, "fooBarbaz", "oBz", 2, 9) + assertMatch(t, FuzzyMatch, true, "fooBarbaz", "fooBarbazz", -1, -1) +} + +func TestExactMatchNaive(t *testing.T) { + assertMatch(t, ExactMatchNaive, false, "fooBarbaz", "oBA", 2, 5) + assertMatch(t, ExactMatchNaive, true, "fooBarbaz", "oBA", -1, -1) + assertMatch(t, ExactMatchNaive, true, "fooBarbaz", "fooBarbazz", -1, -1) +} + +func TestPrefixMatch(t *testing.T) { + assertMatch(t, PrefixMatch, false, "fooBarbaz", "Foo", 0, 3) + assertMatch(t, PrefixMatch, true, "fooBarbaz", "Foo", -1, -1) + assertMatch(t, PrefixMatch, false, "fooBarbaz", "baz", -1, -1) +} + +func TestSuffixMatch(t *testing.T) { + assertMatch(t, SuffixMatch, false, "fooBarbaz", "Foo", -1, -1) + assertMatch(t, SuffixMatch, false, "fooBarbaz", "baz", 6, 9) + assertMatch(t, SuffixMatch, true, "fooBarbaz", "Baz", -1, -1) +} diff --git a/src/cache.go b/src/cache.go new file mode 100644 index 0000000..f2f84a0 --- /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() { + 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..3975eaa --- /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..571a59a --- /dev/null +++ b/src/chunklist.go @@ -0,0 +1,88 @@ +package fzf + +import "sync" + +// Capacity of each chunk +const ChunkSize int = 100 + +// 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(*string, 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 *string, index int) { + *c = append(*c, trans(data, index)) +} + +// 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 string) { + 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) + } + + cl.lastChunk().push(cl.trans, &data, cl.count) + cl.count++ +} + +// 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..02288d9 --- /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 *string, i int) *Item { + return &Item{text: 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("hello") + cl.Push("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 *(*chunk1)[0].text != "hello" || (*chunk1)[0].rank.index != 0 || + *(*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(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("hello") + cl.Push("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..a871570 --- /dev/null +++ b/src/constants.go @@ -0,0 +1,18 @@ +package fzf + +import ( + "github.com/junegunn/fzf/src/util" +) + +// Current version +const Version = "0.9.0" + +// fzf events +const ( + EvtReadNew util.EventType = iota + EvtReadFin + EvtSearchNew + EvtSearchProgress + EvtSearchFin + EvtClose +) diff --git a/src/core.go b/src/core.go new file mode 100644 index 0000000..ee90413 --- /dev/null +++ b/src/core.go @@ -0,0 +1,196 @@ +/* +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" +) + +const coordinatorDelayMax time.Duration = 100 * time.Millisecond +const coordinatorDelayStep time.Duration = 10 * time.Millisecond + +func initProcs() { + runtime.GOMAXPROCS(runtime.NumCPU()) +} + +/* +Reader -> EvtReadFin +Reader -> EvtReadNew -> Matcher (restart) +Terminal -> EvtSearchNew -> Matcher (restart) +Matcher -> EvtSearchProgress -> Terminal (update info) +Matcher -> EvtSearchFin -> Terminal (update list) +*/ + +// Run starts fzf +func Run(options *Options) { + initProcs() + + opts := ParseOptions() + + if opts.Version { + fmt.Println(Version) + os.Exit(0) + } + + // Event channel + eventBox := util.NewEventBox() + + // Chunk list + var chunkList *ChunkList + if len(opts.WithNth) == 0 { + chunkList = NewChunkList(func(data *string, index int) *Item { + return &Item{ + text: data, + index: uint32(index), + rank: Rank{0, 0, uint32(index)}} + }) + } else { + chunkList = NewChunkList(func(data *string, index int) *Item { + tokens := Tokenize(data, opts.Delimiter) + item := Item{ + text: Transform(tokens, opts.WithNth).whole, + origText: data, + index: uint32(index), + rank: Rank{0, 0, uint32(index)}} + return &item + }) + } + + // Reader + reader := Reader{func(str string) { chunkList.Push(str) }, eventBox} + go reader.ReadSource() + + // Matcher + patternBuilder := func(runes []rune) *Pattern { + return BuildPattern( + opts.Mode, opts.Case, opts.Nth, opts.Delimiter, runes) + } + matcher := NewMatcher(patternBuilder, opts.Sort > 0, eventBox) + + // Defered-interactive / Non-interactive + // --select-1 | --exit-0 | --filter + if filtering := opts.Filter != nil; filtering || opts.Select1 || opts.Exit0 { + limit := 0 + var patternString string + if filtering { + patternString = *opts.Filter + } else { + if opts.Select1 || opts.Exit0 { + limit = 1 + } + patternString = opts.Query + } + pattern := patternBuilder([]rune(patternString)) + + looping := true + eventBox.Unwatch(EvtReadNew) + for looping { + eventBox.Wait(func(events *util.Events) { + for evt := range *events { + switch evt { + case EvtReadFin: + looping = false + return + } + } + }) + } + + snapshot, _ := chunkList.Snapshot() + merger, cancelled := matcher.scan(MatchRequest{ + chunks: snapshot, + pattern: pattern}, limit) + + if !cancelled && (filtering || + opts.Exit0 && merger.Length() == 0 || + opts.Select1 && merger.Length() == 1) { + if opts.PrintQuery { + fmt.Println(patternString) + } + for i := 0; i < merger.Length(); i++ { + fmt.Println(merger.Get(i).AsString()) + } + os.Exit(0) + } + } + + // Go interactive + go matcher.Loop() + + // Terminal I/O + terminal := NewTerminal(opts, eventBox) + go terminal.Loop() + + // 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) + + case EvtSearchNew: + snapshot, _ := chunkList.Snapshot() + matcher.Reset(snapshot, terminal.Input(), true) + delay = false + + case EvtSearchProgress: + switch val := value.(type) { + case float32: + terminal.UpdateProgress(val) + } + + case EvtSearchFin: + switch val := value.(type) { + case *Merger: + 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..8ebb583 --- /dev/null +++ b/src/curses/curses.go @@ -0,0 +1,426 @@ +package curses + +/* +#include +#include +#cgo LDFLAGS: -lncurses +void swapOutput() { + FILE* temp = stdout; + stdout = stderr; + stderr = temp; +} +*/ +import "C" + +import ( + "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 + + Del + PgUp + PgDn + + AltB + AltF + AltD + AltBS +) + +// Pallete +const ( + ColNormal = iota + ColPrompt + ColMatch + ColCurrent + ColCurrentMatch + ColSpinner + ColInfo + ColCursor + ColSelected +) + +const ( + doubleClickDuration = 500 * time.Millisecond +) + +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 + _prevDownTime time.Time + _prevDownY int + _clickY []int +) + +func init() { + _prevDownTime = time.Unix(0, 0) + _clickY = []int{} +} + +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(color bool, color256 bool, 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.swapOutput() + + C.setlocale(C.LC_ALL, C.CString("")) + C.initscr() + if mouse { + C.mousemask(C.ALL_MOUSE_EVENTS, nil) + } + C.cbreak() + 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(1) + }() + + if color { + C.start_color() + var bg C.short + if black { + bg = C.COLOR_BLACK + } else { + C.use_default_colors() + bg = -1 + } + if color256 { + C.init_pair(ColPrompt, 110, bg) + C.init_pair(ColMatch, 108, bg) + C.init_pair(ColCurrent, 254, 236) + C.init_pair(ColCurrentMatch, 151, 236) + C.init_pair(ColSpinner, 148, bg) + C.init_pair(ColInfo, 144, bg) + C.init_pair(ColCursor, 161, 236) + C.init_pair(ColSelected, 168, 236) + } else { + C.init_pair(ColPrompt, C.COLOR_BLUE, bg) + C.init_pair(ColMatch, C.COLOR_GREEN, bg) + C.init_pair(ColCurrent, C.COLOR_YELLOW, C.COLOR_BLACK) + C.init_pair(ColCurrentMatch, C.COLOR_GREEN, C.COLOR_BLACK) + C.init_pair(ColSpinner, C.COLOR_GREEN, bg) + C.init_pair(ColInfo, C.COLOR_WHITE, bg) + C.init_pair(ColCursor, C.COLOR_RED, C.COLOR_BLACK) + C.init_pair(ColSelected, C.COLOR_MAGENTA, C.COLOR_BLACK) + } + _color = attrColored + } else { + _color = attrMono + } +} + +func Close() { + C.endwin() + C.swapOutput() +} + +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{CtrlB, 0, nil} + case 67: + return Event{CtrlF, 0, nil} + case 66: + return Event{CtrlJ, 0, nil} + case 65: + return Event{CtrlK, 0, nil} + case 90: + return Event{BTab, 0, nil} + case 72: + return Event{CtrlA, 0, nil} + case 70: + return Event{CtrlE, 0, nil} + case 77: + return mouseSequence(sz) + 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{CtrlE, 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{CtrlA, 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{CtrlA, 0, nil} + case 67: + return Event{CtrlE, 0, nil} + } + case 53: + switch _buf[5] { + case 68: + return Event{AltB, 0, nil} + case 67: + return Event{AltF, 0, nil} + } + } // _buf[4] + } // _buf[3] + } // _buf[2] + } // _buf[2] + } // _buf[1] + 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, CtrlG, CtrlQ: + return Event{CtrlC, 0, nil} + case 127: + return Event{CtrlH, 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) + 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 Refresh() { + C.refresh() +} 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/item.go b/src/item.go new file mode 100644 index 0000000..4cbd3f9 --- /dev/null +++ b/src/item.go @@ -0,0 +1,110 @@ +package fzf + +// Offset holds two 32-bit integers denoting the offsets of a matched substring +type Offset [2]int32 + +// Item represents each input line +type Item struct { + text *string + origText *string + transformed *Transformed + index uint32 + offsets []Offset + rank Rank +} + +// Rank is used to sort the search result +type Rank struct { + matchlen uint16 + strlen uint16 + index uint32 +} + +// Rank calculates rank of the Item +func (i *Item) Rank(cache bool) Rank { + if cache && (i.rank.matchlen > 0 || i.rank.strlen > 0) { + return i.rank + } + matchlen := 0 + prevEnd := 0 + for _, offset := range i.offsets { + begin := int(offset[0]) + end := int(offset[1]) + if prevEnd > begin { + begin = prevEnd + } + if end > prevEnd { + prevEnd = end + } + if end > begin { + matchlen += end - begin + } + } + rank := Rank{uint16(matchlen), uint16(len(*i.text)), i.index} + if cache { + i.rank = rank + } + return rank +} + +// AsString returns the original string +func (i *Item) AsString() string { + if i.origText != nil { + return *i.origText + } + return *i.text +} + +// 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) +} + +func compareRanks(irank Rank, jrank Rank) bool { + if irank.matchlen < jrank.matchlen { + return true + } else if irank.matchlen > jrank.matchlen { + return false + } + + if irank.strlen < jrank.strlen { + return true + } else if irank.strlen > jrank.strlen { + return false + } + + if irank.index <= jrank.index { + return true + } + return false +} diff --git a/src/item_test.go b/src/item_test.go new file mode 100644 index 0000000..0e83631 --- /dev/null +++ b/src/item_test.go @@ -0,0 +1,67 @@ +package fzf + +import ( + "sort" + "testing" +) + +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}) || + !compareRanks(Rank{3, 0, 5}, Rank{3, 0, 6}) || + !compareRanks(Rank{1, 2, 3}, Rank{1, 3, 2}) || + !compareRanks(Rank{0, 0, 0}, Rank{0, 0, 0}) { + t.Error("Invalid order") + } +} + +// Match length, string length, index +func TestItemRank(t *testing.T) { + strs := []string{"foo", "foobar", "bar", "baz"} + item1 := Item{text: &strs[0], index: 1, offsets: []Offset{}} + rank1 := item1.Rank(true) + if rank1.matchlen != 0 || rank1.strlen != 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) + } +} diff --git a/src/matcher.go b/src/matcher.go new file mode 100644 index 0000000..1ea9541 --- /dev/null +++ b/src/matcher.go @@ -0,0 +1,214 @@ +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 +} + +// Matcher is responsible for performing search +type Matcher struct { + patternBuilder func([]rune) *Pattern + sort bool + eventBox *util.EventBox + reqBox *util.EventBox + partitions int + mergerCache map[string]*Merger +} + +const ( + reqRetry util.EventType = iota + reqReset +) + +const ( + progressMinDuration = 200 * time.Millisecond +) + +// NewMatcher returns a new Matcher +func NewMatcher(patternBuilder func([]rune) *Pattern, + sort bool, eventBox *util.EventBox) *Matcher { + return &Matcher{ + patternBuilder: patternBuilder, + sort: sort, + 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() + }) + + // 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, 0) + } + + if !cancelled { + m.mergerCache[patternString] = merger + 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, limit int) (*Merger, bool) { + startedAt := time.Now() + + numChunks := len(request.chunks) + if numChunks == 0 { + return EmptyMerger, false + } + pattern := request.pattern + empty := pattern.IsEmpty() + 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 { + var matches []*Item + if empty { + matches = *chunk + } else { + matches = request.pattern.Match(chunk) + } + sliceMatches = append(sliceMatches, matches...) + if cancelled.Get() { + return + } + countChan <- len(matches) + } + if !empty && m.sort { + 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 limit > 0 && matchCount > limit { + return nil, wait() // For --select-1 and --exit-0 + } + + if count == numChunks { + break + } + + if !empty && m.reqBox.Peak(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, !empty && m.sort), false +} + +// Reset is called to interrupt/signal the ongoing search +func (m *Matcher) Reset(chunks []*Chunk, patternRunes []rune, cancel bool) { + pattern := m.patternBuilder(patternRunes) + + var event util.EventType + if cancel { + event = reqReset + } else { + event = reqRetry + } + m.reqBox.Set(event, MatchRequest{chunks, pattern}) +} diff --git a/src/merger.go b/src/merger.go new file mode 100644 index 0000000..bd2158d --- /dev/null +++ b/src/merger.go @@ -0,0 +1,84 @@ +package fzf + +import "fmt" + +// Merger with no data +var EmptyMerger = NewMerger([][]*Item{}, 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 + cursors []int + sorted bool + count int +} + +// NewMerger returns a new Merger +func NewMerger(lists [][]*Item, sorted bool) *Merger { + mg := Merger{ + lists: lists, + merged: []*Item{}, + cursors: make([]int, len(lists)), + sorted: sorted, + 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 len(mg.lists) == 1 { + return mg.lists[0][idx] + } else if !mg.sorted { + 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)) + } + return mg.mergedGet(idx) +} + +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) { + 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..f79da09 --- /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: &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) + 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) + 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) + 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..e1dba29 --- /dev/null +++ b/src/options.go @@ -0,0 +1,282 @@ +package fzf + +import ( + "fmt" + "os" + "regexp" + "strings" + + "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 the item using index expressions for search + -d, --delimiter=STR Field delimiter regex for --nth (default: AWK-style) + + Search result + -s, --sort Sort the result + +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") + +` + +// 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 +) + +// Options stores the values of command-line options +type Options struct { + Mode Mode + Case Case + Nth []Range + WithNth []Range + Delimiter *regexp.Regexp + Sort int + Multi bool + Mouse bool + Color bool + Color256 bool + Black bool + Reverse bool + Prompt string + Query string + Select1 bool + Exit0 bool + Filter *string + PrintQuery bool + Version bool +} + +func defaultOptions() *Options { + return &Options{ + Mode: ModeFuzzy, + Case: CaseSmart, + Nth: make([]Range, 0), + WithNth: make([]Range, 0), + Delimiter: nil, + Sort: 1000, + Multi: false, + Mouse: true, + Color: true, + Color256: strings.Contains(os.Getenv("TERM"), "256"), + Black: false, + Reverse: false, + Prompt: "> ", + Query: "", + Select1: false, + Exit0: false, + Filter: nil, + PrintQuery: false, + Version: false} +} + +func help(ok int) { + os.Stderr.WriteString(usage) + os.Exit(ok) +} + +func errorExit(msg string) { + os.Stderr.WriteString(msg + "\n") + help(1) +} + +func optString(arg string, prefix string) (bool, string) { + rx, _ := regexp.Compile(fmt.Sprintf("^(?:%s)(.*)$", prefix)) + matches := rx.FindStringSubmatch(arg) + if len(matches) > 1 { + return true, matches[1] + } + return false, "" +} + +func nextString(args []string, i *int, message string) string { + if len(args) > *i+1 { + *i++ + } else { + errorExit(message) + } + return 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) *regexp.Regexp { + rx, e := regexp.Compile(str) + if e != nil { + str = regexp.QuoteMeta(str) + } + + rx, e = regexp.Compile(fmt.Sprintf("(?:.*?%s)|(?:.+?$)", str)) + if e != nil { + errorExit("invalid regular expression: " + e.Error()) + } + return rx +} + +func parseOptions(opts *Options, allArgs []string) { + for i := 0; i < len(allArgs); i++ { + arg := allArgs[i] + switch arg { + case "-h", "--help": + help(0) + 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 "-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 "-i": + opts.Case = CaseIgnore + case "+i": + opts.Case = CaseRespect + case "-m", "--multi": + opts.Multi = true + case "+m", "--no-multi": + opts.Multi = false + case "--no-mouse": + opts.Mouse = false + case "+c", "--no-color": + opts.Color = false + case "+2", "--no-256": + opts.Color256 = false + case "--black": + opts.Black = true + case "--no-black": + opts.Black = false + case "--reverse": + opts.Reverse = true + case "--no-reverse": + opts.Reverse = 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 "--print-query": + opts.PrintQuery = true + case "--no-print-query": + opts.PrintQuery = false + case "--prompt": + opts.Prompt = nextString(allArgs, &i, "prompt string required") + 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 { + errorExit("unknown option: " + arg) + } + } + } +} + +// 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..e10ec56 --- /dev/null +++ b/src/options_test.go @@ -0,0 +1,37 @@ +package fzf + +import "testing" + +func TestDelimiterRegex(t *testing.T) { + rx := delimiterRegexp("*") + tokens := rx.FindAllString("-*--*---**---", -1) + if tokens[0] != "-*" || tokens[1] != "--*" || tokens[2] != "---*" || + tokens[3] != "*" || tokens[4] != "---" { + t.Errorf("%s %s %d", rx, 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") + if len(ranges) != 8 || + ranges[0].begin != rangeEllipsis || ranges[0].end != 3 || + ranges[1].begin != 1 || ranges[1].end != rangeEllipsis || + ranges[2].begin != 2 || ranges[2].end != 3 || + ranges[3].begin != 4 || ranges[3].end != -1 || + 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 { + t.Errorf("%s", ranges) + } + } +} diff --git a/src/pattern.go b/src/pattern.go new file mode 100644 index 0000000..17e3b6b --- /dev/null +++ b/src/pattern.go @@ -0,0 +1,309 @@ +package fzf + +import ( + "regexp" + "sort" + "strings" + + "github.com/junegunn/fzf/src/algo" +) + +const uppercaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +// 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 +) + +type term struct { + typ termType + inv bool + text []rune + origText []rune +} + +// Pattern represents search pattern +type Pattern struct { + mode Mode + caseSensitive bool + text []rune + terms []term + hasInvTerm bool + delimiter *regexp.Regexp + nth []Range + procFun map[termType]func(bool, *string, []rune) (int, int) +} + +var ( + _patternCache map[string]*Pattern + _splitRegex *regexp.Regexp + _cache ChunkCache +) + +func init() { + // 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) + _splitRegex = regexp.MustCompile("\\s+") + _cache = NewChunkCache() +} + +func clearPatternCache() { + _patternCache = make(map[string]*Pattern) +} + +// BuildPattern builds Pattern object from the given arguments +func BuildPattern(mode Mode, caseMode Case, + nth []Range, delimiter *regexp.Regexp, 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 caseMode { + case CaseSmart: + if !strings.ContainsAny(asString, uppercaseLetters) { + runes, caseSensitive = []rune(strings.ToLower(asString)), false + } + case CaseIgnore: + runes, caseSensitive = []rune(strings.ToLower(asString)), false + } + + switch mode { + case ModeExtended, ModeExtendedExact: + terms = parseTerms(mode, string(runes)) + for _, term := range terms { + if term.inv { + hasInvTerm = true + } + } + } + + ptr := &Pattern{ + mode: mode, + caseSensitive: caseSensitive, + text: runes, + terms: terms, + hasInvTerm: hasInvTerm, + nth: nth, + delimiter: delimiter, + procFun: make(map[termType]func(bool, *string, []rune) (int, int))} + + ptr.procFun[termFuzzy] = algo.FuzzyMatch + ptr.procFun[termExact] = algo.ExactMatchNaive + ptr.procFun[termPrefix] = algo.PrefixMatch + ptr.procFun[termSuffix] = algo.SuffixMatch + + _patternCache[asString] = ptr + return ptr +} + +func parseTerms(mode Mode, str string) []term { + tokens := _splitRegex.Split(str, -1) + terms := []term{} + for _, token := range tokens { + typ, inv, text := termFuzzy, false, token + 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 strings.HasPrefix(text, "^") { + 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), + 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 + } + } + } + + var matches []*Item + if p.mode == ModeFuzzy { + matches = p.fuzzyMatch(space) + } else { + matches = p.extendedMatch(space) + } + + if !p.hasInvTerm { + _cache.Add(chunk, cacheKey, matches) + } + return matches +} + +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, + rank: Rank{0, 0, item.index}} +} + +func (p *Pattern) fuzzyMatch(chunk *Chunk) []*Item { + matches := []*Item{} + for _, item := range *chunk { + input := p.prepareInput(item) + if sidx, eidx := p.iter(algo.FuzzyMatch, input, p.text); sidx >= 0 { + matches = append(matches, + dupItem(item, []Offset{Offset{int32(sidx), int32(eidx)}})) + } + } + return matches +} + +func (p *Pattern) extendedMatch(chunk *Chunk) []*Item { + matches := []*Item{} + for _, item := range *chunk { + input := p.prepareInput(item) + offsets := []Offset{} + for _, term := range p.terms { + pfun := p.procFun[term.typ] + if sidx, eidx := p.iter(pfun, input, 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}) + } + } + if len(offsets) == len(p.terms) { + matches = append(matches, dupItem(item, offsets)) + } + } + return matches +} + +func (p *Pattern) prepareInput(item *Item) *Transformed { + if item.transformed != nil { + return item.transformed + } + + var ret *Transformed + if len(p.nth) > 0 { + tokens := Tokenize(item.text, p.delimiter) + ret = Transform(tokens, p.nth) + } else { + trans := Transformed{ + whole: item.text, + parts: []Token{Token{text: item.text, prefixLength: 0}}} + ret = &trans + } + item.transformed = ret + return ret +} + +func (p *Pattern) iter(pfun func(bool, *string, []rune) (int, int), + inputs *Transformed, pattern []rune) (int, int) { + for _, part := range inputs.parts { + prefixLength := part.prefixLength + if sidx, eidx := pfun(p.caseSensitive, 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..4d36eda --- /dev/null +++ b/src/pattern_test.go @@ -0,0 +1,115 @@ +package fzf + +import ( + "testing" + + "github.com/junegunn/fzf/src/algo" +) + +func TestParseTermsExtended(t *testing.T) { + terms := parseTerms(ModeExtended, + "aaa 'bbb ^ccc ddd$ !eee !'fff !^ggg !hhh$") + if len(terms) != 8 || + 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 { + 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, + "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 != termExact || terms[1].inv || len(terms[1].text) != 4 || + 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 != termExact || !terms[5].inv || len(terms[5].text) != 4 || + 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, "' $ ^ !' !^ !$") + if len(terms) != 0 { + t.Errorf("%s", terms) + } +} + +func TestExact(t *testing.T) { + defer clearPatternCache() + clearPatternCache() + pattern := BuildPattern(ModeExtended, CaseSmart, + []Range{}, nil, []rune("'abc")) + str := "aabbcc abc" + sidx, eidx := algo.ExactMatchNaive(pattern.caseSensitive, &str, pattern.terms[0].text) + if sidx != 7 || eidx != 10 { + t.Errorf("%s / %d / %d", pattern.terms, sidx, eidx) + } +} + +func TestCaseSensitivity(t *testing.T) { + defer clearPatternCache() + clearPatternCache() + pat1 := BuildPattern(ModeFuzzy, CaseSmart, []Range{}, nil, []rune("abc")) + clearPatternCache() + pat2 := BuildPattern(ModeFuzzy, CaseSmart, []Range{}, nil, []rune("Abc")) + clearPatternCache() + pat3 := BuildPattern(ModeFuzzy, CaseIgnore, []Range{}, nil, []rune("abc")) + clearPatternCache() + pat4 := BuildPattern(ModeFuzzy, CaseIgnore, []Range{}, nil, []rune("Abc")) + clearPatternCache() + pat5 := BuildPattern(ModeFuzzy, CaseRespect, []Range{}, nil, []rune("abc")) + clearPatternCache() + pat6 := BuildPattern(ModeFuzzy, CaseRespect, []Range{}, nil, []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) { + strptr := func(str string) *string { + return &str + } + pattern := BuildPattern(ModeExtended, CaseSmart, []Range{}, nil, []rune("jg")) + tokens := Tokenize(strptr("junegunn"), nil) + trans := Transform(tokens, []Range{Range{1, 1}}) + + for _, fun := range []func(*Chunk) []*Item{pattern.fuzzyMatch, pattern.extendedMatch} { + chunk := Chunk{ + &Item{ + text: strptr("junegunn"), + origText: strptr("junegunn.choi"), + transformed: trans}, + } + matches := fun(&chunk) + if *matches[0].text != "junegunn" || *matches[0].origText != "junegunn.choi" || + matches[0].offsets[0][0] != 0 || matches[0].offsets[0][1] != 5 || + 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..2c10b8a --- /dev/null +++ b/src/reader.go @@ -0,0 +1,59 @@ +package fzf + +import ( + "bufio" + "io" + "os" + "os/exec" + + "github.com/junegunn/fzf/src/util" +) + +const defaultCommand = `find * -path '*/\.*' -prune -o -type f -print -o -type l -print 2> /dev/null` + +// Reader reads from command or standard input +type Reader struct { + pusher func(string) + eventBox *util.EventBox +} + +// 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) { + if scanner := bufio.NewScanner(src); scanner != nil { + for scanner.Scan() { + r.pusher(scanner.Text()) + r.eventBox.Set(EvtReadNew, nil) + } + } +} + +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..5800b3f --- /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 string) { strs = append(strs, s) }, + eventBox: eb} + + // Check EventBox + if eb.Peak(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.Peak(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.Peak(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.Peak(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..4204a1d --- /dev/null +++ b/src/terminal.go @@ -0,0 +1,626 @@ +package fzf + +import ( + "fmt" + "os" + "regexp" + "sort" + "sync" + "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 { + prompt string + reverse bool + tac bool + cx int + cy int + offset int + yanked []rune + input []rune + multi bool + printQuery bool + count int + progress int + reading bool + merger *Merger + selected map[*string]*string + reqBox *util.EventBox + eventBox *util.EventBox + mutex sync.Mutex + initFunc func() + suppress bool +} + +var _spinner = []string{`-`, `\`, `|`, `/`, `-`, `\`, `|`, `/`} + +const ( + reqPrompt util.EventType = iota + reqInfo + reqList + reqRefresh + reqRedraw + reqClose + reqQuit +) + +const ( + initialDelay = 100 * time.Millisecond + spinnerDuration = 200 * time.Millisecond +) + +// NewTerminal returns new Terminal object +func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { + input := []rune(opts.Query) + return &Terminal{ + prompt: opts.Prompt, + tac: opts.Sort == 0, + reverse: opts.Reverse, + cx: displayWidth(input), + cy: 0, + offset: 0, + yanked: []rune{}, + input: input, + multi: opts.Multi, + printQuery: opts.PrintQuery, + merger: EmptyMerger, + selected: make(map[*string]*string), + reqBox: util.NewEventBox(), + eventBox: eventBox, + mutex: sync.Mutex{}, + suppress: true, + initFunc: func() { + C.Init(opts.Color, opts.Color256, 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) + } +} + +// 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) listIndex(y int) int { + if t.tac { + return t.merger.Length() - y - 1 + } + return y +} + +func (t *Terminal) output() { + if t.printQuery { + fmt.Println(string(t.input)) + } + if len(t.selected) == 0 { + if t.merger.Length() > t.cy { + fmt.Println(t.merger.Get(t.listIndex(t.cy)).AsString()) + } + } else { + for ptr, orig := range t.selected { + if orig != nil { + fmt.Println(*orig) + } else { + fmt.Println(*ptr) + } + } + } +} + +func displayWidth(runes []rune) int { + l := 0 + for _, r := range runes { + l += runewidth.RuneWidth(r) + } + return l +} + +func (t *Terminal) move(y int, x int, clear bool) { + maxy := C.MaxY() + if !t.reverse { + y = maxy - y - 1 + } + + 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() { + 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.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) printList() { + t.constrain() + + maxy := maxItems() + count := t.merger.Length() - t.offset + for i := 0; i < maxy; i++ { + t.move(i+2, 0, true) + if i < count { + t.printItem(t.merger.Get(t.listIndex(i+t.offset)), i == t.cy-t.offset) + } + } +} + +func (t *Terminal) printItem(item *Item, current bool) { + _, selected := t.selected[item.text] + if current { + C.CPrint(C.ColCursor, true, ">") + if selected { + C.CPrint(C.ColCurrent, true, ">") + } else { + C.CPrint(C.ColCurrent, true, " ") + } + t.printHighlighted(item, true, C.ColCurrent, C.ColCurrentMatch) + } else { + C.CPrint(C.ColCursor, true, " ") + if selected { + C.CPrint(C.ColSelected, true, ">") + } else { + C.Print(" ") + } + t.printHighlighted(item, false, 0, C.ColMatch) + } +} + +func trimRight(runes []rune, width int) ([]rune, int) { + currentWidth := displayWidth(runes) + trimmed := 0 + + for currentWidth > width && len(runes) > 0 { + sz := len(runes) + currentWidth -= runewidth.RuneWidth(runes[sz-1]) + runes = runes[:sz-1] + trimmed++ + } + return runes, trimmed +} + +func trimLeft(runes []rune, width int) ([]rune, int32) { + currentWidth := displayWidth(runes) + var trimmed int32 + + for currentWidth > width && len(runes) > 0 { + currentWidth -= runewidth.RuneWidth(runes[0]) + runes = runes[1:] + trimmed++ + } + return runes, trimmed +} + +func (*Terminal) printHighlighted(item *Item, bold bool, col1 int, col2 int) { + var maxe int32 + for _, offset := range item.offsets { + if offset[1] > maxe { + maxe = offset[1] + } + } + + // Overflow + text := []rune(*item.text) + offsets := item.offsets + maxWidth := C.MaxX() - 3 + fullWidth := displayWidth(text) + if fullWidth > maxWidth { + // 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 + offsets = make([]Offset, len(item.offsets)) + for idx, offset := range item.offsets { + b, e := offset[0], offset[1] + b += 2 - diff + e += 2 - diff + b = util.Max32(b, 2) + if b < e { + offsets[idx] = Offset{b, e} + } + } + text = append([]rune(".."), text...) + } + } + + sort.Sort(ByOrder(offsets)) + var index int32 + for _, offset := range offsets { + b := util.Max32(index, offset[0]) + e := util.Max32(index, offset[1]) + C.CPrint(col1, bold, string(text[index:b])) + C.CPrint(col2, bold, string(text[b:e])) + index = e + } + if index < int32(len(text)) { + C.CPrint(col1, bold, string(text[index:])) + } +} + +func (t *Terminal) printAll() { + t.printList() + t.printInfo() + t.printPrompt() +} + +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...) +} + +// Loop is called to start Terminal I/O +func (t *Terminal) Loop() { + { // Late initialization + t.mutex.Lock() + t.initFunc() + t.printPrompt() + t.placeCursor() + C.Refresh() + t.printInfo() + t.mutex.Unlock() + go func() { + timer := time.NewTimer(initialDelay) + <-timer.C + t.reqBox.Set(reqRefresh, nil) + }() + } + + 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() + case reqInfo: + t.printInfo() + case reqList: + t.printList() + case reqRefresh: + t.suppress = false + case reqRedraw: + C.Clear() + t.printAll() + case reqClose: + C.Close() + t.output() + os.Exit(0) + case reqQuit: + C.Close() + os.Exit(1) + } + } + 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 + } + } + } + toggle := func() { + idx := t.listIndex(t.cy) + if idx < t.merger.Length() { + item := t.merger.Get(idx) + if _, found := t.selected[item.text]; !found { + t.selected[item.text] = item.origText + } else { + delete(t.selected, item.text) + } + req(reqInfo) + } + } + switch event.Type { + case C.Invalid: + t.mutex.Unlock() + continue + case C.CtrlA: + t.cx = 0 + case C.CtrlB: + if t.cx > 0 { + t.cx-- + } + case C.CtrlC, C.CtrlG, C.CtrlQ, C.ESC: + req(reqQuit) + case C.CtrlD: + if !t.delChar() && t.cx == 0 { + req(reqQuit) + } + case C.CtrlE: + t.cx = len(t.input) + case C.CtrlF: + if t.cx < len(t.input) { + t.cx++ + } + case C.CtrlH: + if t.cx > 0 { + t.input = append(t.input[:t.cx-1], t.input[t.cx:]...) + t.cx-- + } + case C.Tab: + if t.multi && t.merger.Length() > 0 { + toggle() + t.vmove(-1) + req(reqList) + } + case C.BTab: + if t.multi && t.merger.Length() > 0 { + toggle() + t.vmove(1) + req(reqList) + } + case C.CtrlJ, C.CtrlN: + t.vmove(-1) + req(reqList) + case C.CtrlK, C.CtrlP: + t.vmove(1) + req(reqList) + case C.CtrlM: + req(reqClose) + case C.CtrlL: + req(reqRedraw) + case C.CtrlU: + if t.cx > 0 { + t.yanked = copySlice(t.input[:t.cx]) + t.input = t.input[t.cx:] + t.cx = 0 + } + case C.CtrlW: + if t.cx > 0 { + t.rubout("\\s\\S") + } + case C.AltBS: + if t.cx > 0 { + t.rubout("[^[:alnum:]][[:alnum:]]") + } + case C.CtrlY: + t.input = append(append(t.input[:t.cx], t.yanked...), t.input[t.cx:]...) + t.cx += len(t.yanked) + case C.Del: + t.delChar() + case C.PgUp: + t.vmove(maxItems() - 1) + req(reqList) + case C.PgDn: + t.vmove(-(maxItems() - 1)) + req(reqList) + case C.AltB: + t.cx = findLastMatch("[^[:alnum:]][[:alnum:]]", string(t.input[:t.cx])) + 1 + case C.AltF: + t.cx += findFirstMatch("[[:alnum:]][^[:alnum:]]|(.$)", string(t.input[t.cx:])) + 1 + case C.AltD: + 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 C.Rune: + prefix := copySlice(t.input[:t.cx]) + t.input = append(append(prefix, event.Char), t.input[t.cx:]...) + t.cx++ + case C.Mouse: + me := event.MouseEvent + mx, my := util.Constrain(me.X-len(t.prompt), 0, len(t.input)), me.Y + if !t.reverse { + my = C.MaxY() - my - 1 + } + if me.S != 0 { + // Scroll + if t.merger.Length() > 0 { + if t.multi && me.Mod { + toggle() + } + t.vmove(me.S) + req(reqList) + } + } else if me.Double { + // Double-click + if my >= 2 { + if t.vset(my-2) && t.listIndex(t.cy) < t.merger.Length() { + req(reqClose) + } + } + } else if me.Down { + if my == 0 && mx >= 0 { + // Prompt + t.cx = mx + } else if my >= 2 { + // List + if t.vset(t.offset+my-2) && 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, nil) + } + for _, event := range events { + t.reqBox.Set(event, nil) + } + } +} + +func (t *Terminal) constrain() { + count := t.merger.Length() + height := C.MaxY() - 2 + 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) + } +} + +func (t *Terminal) vmove(o int) { + if t.reverse { + t.vset(t.cy - o) + } else { + t.vset(t.cy + o) + } +} + +func (t *Terminal) vset(o int) bool { + t.cy = util.Constrain(o, 0, t.merger.Length()-1) + return t.cy == o +} + +func maxItems() int { + return C.MaxY() - 2 +} diff --git a/src/tokenizer.go b/src/tokenizer.go new file mode 100644 index 0000000..26aebd9 --- /dev/null +++ b/src/tokenizer.go @@ -0,0 +1,204 @@ +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 +} + +// Transformed holds the result of tokenization and transformation +type Transformed struct { + whole *string + parts []Token +} + +// Token contains the tokenized part of the strings and its prefix length +type Token struct { + text *string + prefixLength int +} + +// ParseRange parses nth-expression and returns the corresponding Range object +func ParseRange(str *string) (Range, bool) { + if (*str) == ".." { + return Range{rangeEllipsis, rangeEllipsis}, true + } else if strings.HasPrefix(*str, "..") { + end, err := strconv.Atoi((*str)[2:]) + if err != nil || end == 0 { + return Range{}, false + } + return Range{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 Range{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 { + return Range{}, false + } + return Range{begin, end}, true + } + + n, err := strconv.Atoi(*str) + if err != nil || n == 0 { + return Range{}, false + } + return Range{n, n}, true +} + +func withPrefixLengths(tokens []string, 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 + str := token + ret[idx] = Token{text: &str, prefixLength: prefixLength} + prefixLength += len([]rune(token)) + } + return ret +} + +const ( + awkNil = iota + awkBlack + awkWhite +) + +func awkTokenizer(input *string) ([]string, int) { + // 9, 32 + ret := []string{} + str := []rune{} + prefixLength := 0 + state := awkNil + for _, r := range []rune(*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, string(str)) + state = awkBlack + str = []rune{r} + } + } + } + if len(str) > 0 { + ret = append(ret, string(str)) + } + return ret, prefixLength +} + +// Tokenize tokenizes the given string with the delimiter +func Tokenize(str *string, delimiter *regexp.Regexp) []Token { + if delimiter == nil { + // AWK-style (\S+\s*) + tokens, prefixLength := awkTokenizer(str) + return withPrefixLengths(tokens, prefixLength) + } + tokens := delimiter.FindAllString(*str, -1) + return withPrefixLengths(tokens, 0) +} + +func joinTokens(tokens []Token) string { + ret := "" + for _, token := range tokens { + ret += *token.text + } + return ret +} + +// Transform is used to transform the input when --with-nth option is given +func Transform(tokens []Token, withNth []Range) *Transformed { + transTokens := make([]Token, len(withNth)) + numTokens := len(tokens) + whole := "" + for idx, r := range withNth { + part := "" + minIdx := 0 + if r.begin == r.end { + idx := r.begin + if idx == rangeEllipsis { + part += joinTokens(tokens) + } else { + if idx < 0 { + idx += numTokens + 1 + } + if idx >= 1 && idx <= numTokens { + minIdx = idx - 1 + 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 += *tokens[idx-1].text + } + } + } + whole += part + var prefixLength int + if minIdx < numTokens { + prefixLength = tokens[minIdx].prefixLength + } else { + prefixLength = 0 + } + transTokens[idx] = Token{&part, prefixLength} + } + return &Transformed{ + whole: &whole, + parts: transTokens} +} diff --git a/src/tokenizer_test.go b/src/tokenizer_test.go new file mode 100644 index 0000000..5195a1b --- /dev/null +++ b/src/tokenizer_test.go @@ -0,0 +1,101 @@ +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(&input, nil) + if *tokens[0].text != "abc: " || tokens[0].prefixLength != 2 { + t.Errorf("%s", tokens) + } + + // With delimiter + tokens = Tokenize(&input, delimiterRegexp(":")) + if *tokens[0].text != " abc:" || tokens[0].prefixLength != 0 { + t.Errorf("%s", tokens) + } +} + +func TestTransform(t *testing.T) { + input := " abc: def: ghi: jkl" + { + tokens := Tokenize(&input, nil) + { + ranges := splitNth("1,2,3") + tx := Transform(tokens, ranges) + if *tx.whole != "abc: def: ghi: " { + t.Errorf("%s", *tx) + } + } + { + ranges := splitNth("1..2,3,2..,1") + tx := Transform(tokens, ranges) + if *tx.whole != "abc: def: ghi: def: ghi: jklabc: " || + len(tx.parts) != 4 || + *tx.parts[0].text != "abc: def: " || tx.parts[0].prefixLength != 2 || + *tx.parts[1].text != "ghi: " || tx.parts[1].prefixLength != 14 || + *tx.parts[2].text != "def: ghi: jkl" || tx.parts[2].prefixLength != 8 || + *tx.parts[3].text != "abc: " || tx.parts[3].prefixLength != 2 { + t.Errorf("%s", *tx) + } + } + } + { + tokens := Tokenize(&input, delimiterRegexp(":")) + { + ranges := splitNth("1..2,3,2..,1") + tx := Transform(tokens, ranges) + if *tx.whole != " abc: def: ghi: def: ghi: jkl abc:" || + len(tx.parts) != 4 || + *tx.parts[0].text != " abc: def:" || tx.parts[0].prefixLength != 0 || + *tx.parts[1].text != " ghi:" || tx.parts[1].prefixLength != 12 || + *tx.parts[2].text != " def: ghi: jkl" || tx.parts[2].prefixLength != 6 || + *tx.parts[3].text != " abc:" || tx.parts[3].prefixLength != 0 { + t.Errorf("%s", *tx) + } + } + } +} + +func TestTransformIndexOutOfBounds(t *testing.T) { + Transform([]Token{}, splitNth("1")) +} 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..568ad9f --- /dev/null +++ b/src/util/eventbox.go @@ -0,0 +1,80 @@ +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) + } +} + +// Peak peaks at the event box if the given event is set +func (b *EventBox) Peak(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 + } +} 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..14833c0 --- /dev/null +++ b/src/util/util.go @@ -0,0 +1,56 @@ +package util + +// #include +import "C" + +import ( + "os" + "time" +) + +// 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 +} + +// Max32 returns the largest 32-bit integer +func Max32(first int32, second int32) int32 { + if first > second { + return first + } + return second +} + +// 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 +} 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) + } +}