├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml ├── no-response.yml └── workflows │ ├── approve_and_merge.yml │ └── main.yml ├── .gitignore ├── .goreleaser.yml ├── CREDITS ├── LICENSE ├── Makefile ├── README.md ├── codecov.yml ├── example ├── cli │ └── main.go ├── go.mod ├── go.sum ├── hotreload │ └── main.go └── track │ └── main.go ├── example_test.go ├── fuzz_test.go ├── fuzzing_test.go ├── fuzzyfinder.go ├── fuzzyfinder_test.go ├── go.mod ├── go.sum ├── helper_test.go ├── matching ├── matching.go └── matching_test.go ├── mock.go ├── mock_test.go ├── option.go ├── scoring ├── scoring.go ├── scoring_test.go ├── smith_waterman.go └── smith_waterman_test.go ├── tcell.go ├── testdata └── fixtures │ ├── testfind-arrow_left-right.golden │ ├── testfind-arrow_left_backspace.golden │ ├── testfind-arrow_up-down.golden │ ├── testfind-backspace.golden │ ├── testfind-backspace2.golden │ ├── testfind-backspace_doesnt_change_x_if_cursorx_is_0.golden │ ├── testfind-backspace_empty.golden │ ├── testfind-ctrl-e.golden │ ├── testfind-ctrl-u.golden │ ├── testfind-ctrl-w.golden │ ├── testfind-ctrl-w_empty.golden │ ├── testfind-cursor_begins_at_top.golden │ ├── testfind-delete.golden │ ├── testfind-delete_empty.golden │ ├── testfind-header_line.golden │ ├── testfind-header_line_which_exceeds_max_charaters.golden │ ├── testfind-initial.golden │ ├── testfind-input_glow.golden │ ├── testfind-input_lo.golden │ ├── testfind-long_item.golden │ ├── testfind-paging.golden │ ├── testfind-pg-dn.golden │ ├── testfind-pg-dn_twice.golden │ ├── testfind-pg-up.golden │ ├── testfind-pg-up_twice.golden │ ├── testfind-tab_doesnt_work.golden │ ├── testfind_hotreload.golden │ ├── testfind_hotreloadlock.golden │ ├── testfind_withcontext.golden │ ├── testfind_withpreviewwindow-multiline.golden │ ├── testfind_withpreviewwindow-normal.golden │ ├── testfind_withpreviewwindow-overflowed_line.golden │ ├── testfind_withpreviewwindow-sgr.golden │ ├── testfind_withpreviewwindow-sgr_with_overflowed_line.golden │ ├── testfind_withquery-has_initial_query.golden │ ├── testfind_withquery-no_initial_query.golden │ ├── testfind_withselectone-has_initial_query.golden │ ├── testfind_withselectone-more_than_one.golden │ └── testfind_withselectone-only_one_option.golden └── tools ├── go.mod ├── go.sum └── tools.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ktr0731] 4 | custom: https://paypal.me/ktr0731 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | ## To reproduce 13 | 17 | 18 | ## Expected behavior 19 | 20 | ## Screenshots 21 | 22 | 23 | ## Environment 24 | - OS: 25 | - Terminal: 26 | - go-fuzzyfinder version: 27 | 28 | ## Additional context 29 | 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | labels: 8 | - dependency 9 | reviewers: 10 | - ktr0731 11 | - package-ecosystem: gomod 12 | directory: / 13 | schedule: 14 | interval: weekly 15 | labels: 16 | - dependency 17 | reviewers: 18 | - ktr0731 19 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | daysUntilClose: 7 2 | responseRequiredLabel: 'response needed' 3 | closeComment: > 4 | This issue has been automatically closed because there has been no response 5 | to our request for more information from the original author. With only the 6 | information that is currently in the issue, we don't have enough information 7 | to take action. Please reach out if you have or find the answers we need so 8 | that we can investigate further. 9 | -------------------------------------------------------------------------------- /.github/workflows/approve_and_merge.yml: -------------------------------------------------------------------------------- 1 | name: "Auto approve Pull Requests and enable auto-merge" 2 | on: 3 | pull_request_target 4 | permissions: 5 | pull-requests: write 6 | jobs: 7 | worker: 8 | runs-on: ubuntu-latest 9 | if: github.actor == 'dependabot[bot]' 10 | steps: 11 | - name: Dependabot metadata 12 | id: metadata 13 | uses: dependabot/fetch-metadata@v2.4.0 14 | with: 15 | github-token: ${{secrets.GH_TOKEN}} 16 | - name: Approve 17 | run: gh pr review --approve $PR_URL 18 | env: 19 | PR_URL: ${{github.event.pull_request.html_url}} 20 | GITHUB_TOKEN: ${{secrets.GH_TOKEN}} 21 | - name: Enable auto-merge 22 | run: gh pr merge --auto --squash $PR_URL 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GH_TOKEN}} 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Build on ${{ matrix.os }} with Go ${{ matrix.go }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, windows-latest, macOS-latest] 10 | go: ['1.24'] 11 | steps: 12 | - name: Set up Go ${{ matrix.go }} 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: ${{ matrix.go }} 16 | 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@v4 19 | 20 | - name: Download dependencies 21 | run: go mod download 22 | 23 | - name: Cache modules 24 | uses: actions/cache@v3 25 | with: 26 | path: ~/go/pkg/mod 27 | key: ${{ runner.OS }}-go-${{ hashFiles('**/go.sum') }} 28 | 29 | - name: Build 30 | run: go build 31 | 32 | - name: Test 33 | run: go test -coverpkg ./... -covermode atomic -coverprofile coverage.txt -tags fuzz -numCases 3000 -numEvents 10 ./... 34 | 35 | - name: Lint 36 | run: go vet ./... 37 | 38 | - name: Upload coverage to Codecov 39 | uses: codecov/codecov-action@v5.4.3 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | file: ./coverage.txt 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/38b189d3b9ea5a14f7177db78d13e2e1cc0e0092/Go.gitignore 2 | 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | coverage.txt 17 | fuzz.out 18 | _tools 19 | .idea 20 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - skip: true 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2021 ktr0731 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | export GOBIN := $(PWD)/_tools 4 | export PATH := $(GOBIN):$(PATH) 5 | export GO111MODULE := on 6 | 7 | .PHONY: generate 8 | generate: 9 | go generate ./... 10 | 11 | .PHONY: tools 12 | tools: 13 | @cat tools/tools.go | grep -E '^\s*_\s.*' | awk '{ print $$2 }' | xargs go install 14 | 15 | .PHONY: build 16 | build: 17 | go build ./... 18 | 19 | .PHONY: test 20 | test: format unit-test credits 21 | 22 | .PHONY: format 23 | format: 24 | go mod tidy 25 | 26 | .PHONY: credits 27 | credits: 28 | gocredits -skip-missing . > CREDITS 29 | 30 | .PHONY: unit-test 31 | unit-test: lint 32 | go test -race ./... 33 | 34 | .PHONY: lint 35 | lint: 36 | go vet ./... 37 | 38 | .PHONY: coverage 39 | coverage: 40 | DEBUG=true go test -coverpkg ./... -covermode=atomic -coverprofile=coverage.txt -race $(shell go list ./...) 41 | 42 | .PHONY: coverage-web 43 | coverage-web: coverage 44 | go tool cover -html=coverage.txt 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-fuzzyfinder 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/ktr0731/go-fuzzyfinder)](https://pkg.go.dev/github.com/ktr0731/go-fuzzyfinder) 4 | [![GitHub Actions](https://github.com/ktr0731/go-fuzzyfinder/workflows/main/badge.svg)](https://github.com/ktr0731/go-fuzzyfinder/actions) 5 | [![codecov](https://codecov.io/gh/ktr0731/go-fuzzyfinder/branch/master/graph/badge.svg?token=RvpSTKDJGO)](https://codecov.io/gh/ktr0731/go-fuzzyfinder) 6 | 7 | `go-fuzzyfinder` is a Go library that provides fuzzy-finding with an fzf-like terminal user interface. 8 | 9 | ![](https://user-images.githubusercontent.com/12953836/52424222-e1edc900-2b3c-11e9-8158-8e193844252a.png) 10 | 11 | ## Installation 12 | ``` bash 13 | $ go get github.com/ktr0731/go-fuzzyfinder 14 | ``` 15 | 16 | ## Usage 17 | `go-fuzzyfinder` provides two functions, `Find` and `FindMulti`. 18 | `FindMulti` can select multiple lines. It is similar to `fzf -m`. 19 | 20 | This is [an example](//github.com/ktr0731/go-fuzzyfinder/blob/master/example/track/main.go) of `FindMulti`. 21 | 22 | ``` go 23 | type Track struct { 24 | Name string 25 | AlbumName string 26 | Artist string 27 | } 28 | 29 | var tracks = []Track{ 30 | {"foo", "album1", "artist1"}, 31 | {"bar", "album1", "artist1"}, 32 | {"foo", "album2", "artist1"}, 33 | {"baz", "album2", "artist2"}, 34 | {"baz", "album3", "artist2"}, 35 | } 36 | 37 | func main() { 38 | idx, err := fuzzyfinder.FindMulti( 39 | tracks, 40 | func(i int) string { 41 | return tracks[i].Name 42 | }, 43 | fuzzyfinder.WithPreviewWindow(func(i, w, h int) string { 44 | if i == -1 { 45 | return "" 46 | } 47 | return fmt.Sprintf("Track: %s (%s)\nAlbum: %s", 48 | tracks[i].Name, 49 | tracks[i].Artist, 50 | tracks[i].AlbumName) 51 | })) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | fmt.Printf("selected: %v\n", idx) 56 | } 57 | ``` 58 | 59 | The execution result prints selected item's indexes. 60 | 61 | ### Preselecting items 62 | 63 | You can preselect items using the `WithPreselected` option. It works in both `Find` and `FindMulti`. 64 | 65 | ``` go 66 | // Single selection mode 67 | // The cursor will be positioned on the first item that matches the predicate 68 | idx, err := fuzzyfinder.Find( 69 | tracks, 70 | func(i int) string { 71 | return tracks[i].Name 72 | }, 73 | fuzzyfinder.WithPreselected(func(i int) bool { 74 | return tracks[i].Name == "bar" 75 | }), 76 | ) 77 | 78 | // Multi selection mode 79 | // All items that match the predicate will be selected initially 80 | idxs, err := fuzzyfinder.FindMulti( 81 | tracks, 82 | func(i int) string { 83 | return tracks[i].Name 84 | }, 85 | fuzzyfinder.WithPreselected(func(i int) bool { 86 | return tracks[i].Artist == "artist2" 87 | }), 88 | ) 89 | ``` 90 | 91 | ## Motivation 92 | Fuzzy-finder command-line tools such that 93 | [fzf](https://github.com/junegunn/fzf), [fzy](https://github.com/jhawthorn/fzy), or [skim](https://github.com/lotabout/skim) 94 | are very powerful to find out specified lines interactively. 95 | However, there are limits to deal with fuzzy-finder's features in several cases. 96 | 97 | First, it is hard to distinguish between two or more entities that have the same text. 98 | In the example of ktr0731/itunes-cli, it is possible to conflict tracks such that same track names, but different artists. 99 | To avoid such conflicts, we have to display the artist names with each track name. 100 | It seems like the problem has been solved, but it still has the problem. 101 | It is possible to conflict in case of same track names, same artists, but other albums, which each track belongs to. 102 | This problem is difficult to solve because pipes and filters are row-based mechanisms, there are no ways to hold references that point list entities. 103 | 104 | The second issue occurs in the case of incorporating a fuzzy-finder as one of the main features in a command-line tool such that [enhancd](https://github.com/b4b4r07/enhancd) or [itunes-cli](https://github.com/ktr0731/itunes-cli). 105 | Usually, these tools require that it has been installed one fuzzy-finder as a precondition. 106 | In addition, to deal with the fuzzy-finder, an environment variable configuration such that `export TOOL_NAME_FINDER=fzf` is required. 107 | It is a bother and complicated. 108 | 109 | `go-fuzzyfinder` resolves above issues. 110 | Dealing with the first issue, `go-fuzzyfinder` provides the preview-window feature (See an example in [Usage](#usage)). 111 | Also, by using `go-fuzzyfinder`, built tools don't require any fuzzy-finders. 112 | 113 | ## See Also 114 | - [Fuzzy-finder as a Go library](https://medium.com/@ktr0731/fuzzy-finder-as-a-go-library-590b7458200f) 115 | - [(Japanese) fzf ライクな fuzzy-finder を提供する Go ライブラリを書いた](https://syfm.hatenablog.com/entry/2019/02/09/120000) 116 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "70...100" 9 | 10 | status: 11 | changes: no 12 | patch: no 13 | project: 14 | default: 15 | target: 85 16 | 17 | parsers: 18 | gcov: 19 | branch_detection: 20 | conditional: yes 21 | loop: yes 22 | method: no 23 | macro: no 24 | 25 | comment: 26 | layout: "header, diff" 27 | behavior: default 28 | require_changes: no 29 | 30 | ignore: 31 | - tcell.go 32 | - mock.go 33 | -------------------------------------------------------------------------------- /example/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 11 | isatty "github.com/mattn/go-isatty" 12 | "github.com/spf13/pflag" 13 | ) 14 | 15 | var multi = pflag.BoolP("multi", "m", false, "multi-select") 16 | 17 | func main() { 18 | if isatty.IsTerminal(os.Stdin.Fd()) { 19 | fmt.Println("please use pipe") 20 | return 21 | } 22 | b, err := ioutil.ReadAll(os.Stdin) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | slice := strings.Split(string(b), "\n") 27 | 28 | idxs, err := fuzzyfinder.FindMulti( 29 | slice, 30 | func(i int) string { 31 | return slice[i] 32 | }) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | for _, idx := range idxs { 37 | fmt.Println(slice[idx]) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ktr0731/go-fuzzyfinder/example 2 | 3 | go 1.21 4 | 5 | toolchain go1.24.1 6 | 7 | replace github.com/ktr0731/go-fuzzyfinder => ../ 8 | 9 | require ( 10 | github.com/ktr0731/go-fuzzyfinder v0.6.0 11 | github.com/mattn/go-isatty v0.0.16 12 | github.com/spf13/pflag v1.0.5 13 | ) 14 | 15 | require ( 16 | github.com/gdamore/encoding v1.0.0 // indirect 17 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 18 | github.com/ktr0731/go-ansisgr v0.1.0 // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/mattn/go-runewidth v0.0.16 // indirect 21 | github.com/nsf/termbox-go v1.1.1 // indirect 22 | github.com/pkg/errors v0.9.1 // indirect 23 | github.com/rivo/uniseg v0.4.3 // indirect 24 | golang.org/x/sys v0.5.0 // indirect 25 | golang.org/x/term v0.5.0 // indirect 26 | golang.org/x/text v0.7.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 2 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 3 | github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= 4 | github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= 5 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 7 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 8 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 9 | github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= 10 | github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= 11 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 12 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 13 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 14 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 15 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 16 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 17 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 18 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 19 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 20 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 21 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 22 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 23 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 24 | github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= 25 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 26 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 27 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 28 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 31 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 32 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 33 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 34 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 35 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 37 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 38 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 44 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 46 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 47 | golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= 48 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 49 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 50 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 51 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 52 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 53 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 54 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 55 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 56 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 57 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | -------------------------------------------------------------------------------- /example/hotreload/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 12 | isatty "github.com/mattn/go-isatty" 13 | "github.com/spf13/pflag" 14 | ) 15 | 16 | var multi = pflag.BoolP("multi", "m", false, "multi-select") 17 | 18 | func main() { 19 | if isatty.IsTerminal(os.Stdin.Fd()) { 20 | fmt.Println("please use pipe") 21 | return 22 | } 23 | var slice []string 24 | var mut sync.RWMutex 25 | go func(slice *[]string) { 26 | s := bufio.NewScanner(os.Stdin) 27 | for s.Scan() { 28 | mut.Lock() 29 | *slice = append(*slice, s.Text()) 30 | mut.Unlock() 31 | time.Sleep(50 * time.Millisecond) // to give a feeling of how it looks like in the terminal 32 | } 33 | }(&slice) 34 | 35 | idxs, err := fuzzyfinder.FindMulti( 36 | &slice, 37 | func(i int) string { 38 | return slice[i] 39 | }, 40 | fuzzyfinder.WithHotReloadLock(mut.RLocker()), 41 | ) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | for _, idx := range idxs { 46 | fmt.Println(slice[idx]) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/track/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 8 | ) 9 | 10 | type Track struct { 11 | Name string 12 | AlbumName string 13 | Artist string 14 | } 15 | 16 | var tracks = []Track{ 17 | {"foo", "album1", "artist1"}, 18 | {"bar", "album1", "artist1"}, 19 | {"foo", "album2", "artist1"}, 20 | {"baz", "album2", "artist2"}, 21 | {"baz", "album3", "artist2"}, 22 | } 23 | 24 | func main() { 25 | singleExample() 26 | multiExample() 27 | } 28 | 29 | func singleExample() { 30 | idx, err := fuzzyfinder.Find( 31 | tracks, 32 | func(i int) string { 33 | return tracks[i].Name 34 | }, 35 | fuzzyfinder.WithPreviewWindow(func(i, w, h int) string { 36 | if i == -1 { 37 | return "" 38 | } 39 | return fmt.Sprintf("Track: %s (%s)\nAlbum: %s", 40 | tracks[i].Name, 41 | tracks[i].Artist, 42 | tracks[i].AlbumName) 43 | }), 44 | fuzzyfinder.WithPreselected(func(i int) bool { 45 | return i == 1 46 | }), 47 | ) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | fmt.Printf("Selected: %s\n", tracks[idx].Name) 52 | } 53 | 54 | func multiExample() { 55 | idxs, err := fuzzyfinder.FindMulti( 56 | tracks, 57 | func(i int) string { 58 | return tracks[i].Name 59 | }, 60 | fuzzyfinder.WithPreviewWindow(func(i, w, h int) string { 61 | if i == -1 { 62 | return "" 63 | } 64 | return fmt.Sprintf("Track: %s (%s)\nAlbum: %s", 65 | tracks[i].Name, 66 | tracks[i].Artist, 67 | tracks[i].AlbumName) 68 | }), 69 | fuzzyfinder.WithPreselected(func(i int) bool { 70 | return tracks[i].Artist == "artist2" 71 | }), 72 | ) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | 77 | fmt.Println("Selected:") 78 | for _, idx := range idxs { 79 | fmt.Printf("- %v (%s)\n", idx, tracks[idx].Name) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 9 | ) 10 | 11 | func ExampleFind() { 12 | slice := []struct { 13 | id string 14 | name string 15 | }{ 16 | {"id1", "foo"}, 17 | {"id2", "bar"}, 18 | {"id3", "baz"}, 19 | } 20 | idx, _ := fuzzyfinder.Find(slice, func(i int) string { 21 | return fmt.Sprintf("[%s] %s", slice[i].id, slice[i].name) 22 | }) 23 | fmt.Println(slice[idx]) // The selected item. 24 | } 25 | 26 | func ExampleFind_previewWindow() { 27 | slice := []struct { 28 | id string 29 | name string 30 | }{ 31 | {"id1", "foo"}, 32 | {"id2", "bar"}, 33 | {"id3", "baz"}, 34 | } 35 | idx, _ := fuzzyfinder.Find( 36 | slice, 37 | func(i int) string { 38 | return fmt.Sprintf("[%s] %s", slice[i].id, slice[i].name) 39 | }, 40 | fuzzyfinder.WithPreviewWindow(func(i, width, _ int) string { 41 | if i == -1 { 42 | return "no results" 43 | } 44 | s := fmt.Sprintf("%s is selected", slice[i].name) 45 | // As an example of using width, if the window width is less than 46 | // the length of s, we return the name directly. 47 | if width < len([]rune(s)) { 48 | return slice[i].name 49 | } 50 | return s 51 | })) 52 | fmt.Println(slice[idx]) // The selected item. 53 | } 54 | 55 | func ExampleFindMulti() { 56 | slice := []struct { 57 | id string 58 | name string 59 | }{ 60 | {"id1", "foo"}, 61 | {"id2", "bar"}, 62 | {"id3", "baz"}, 63 | } 64 | idxs, _ := fuzzyfinder.FindMulti(slice, func(i int) string { 65 | return fmt.Sprintf("[%s] %s", slice[i].id, slice[i].name) 66 | }) 67 | for _, idx := range idxs { 68 | fmt.Println(slice[idx]) 69 | } 70 | } 71 | 72 | func ExampleTerminalMock() { 73 | // Initialize a mocked terminal. 74 | term := fuzzyfinder.UseMockedTerminalV2() 75 | keys := "foo" 76 | for _, r := range keys { 77 | term.InjectKey(tcell.KeyRune, r, tcell.ModNone) 78 | } 79 | term.InjectKey(tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone) 80 | 81 | slice := []string{"foo", "bar", "baz"} 82 | _, _ = fuzzyfinder.Find(slice, func(i int) string { return slice[i] }) 83 | 84 | // Write out the execution result to a temp file. 85 | // We can test it by the golden files testing pattern. 86 | // 87 | // See https://speakerdeck.com/mitchellh/advanced-testing-with-go?slide=19 88 | result := term.GetResult() 89 | _ = ioutil.WriteFile("ui.out", []byte(result), 0600) 90 | } 91 | -------------------------------------------------------------------------------- /fuzz_test.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 9 | ) 10 | 11 | func FuzzPreviewWindow(f *testing.F) { 12 | slice := []string{"foo"} 13 | 14 | f.Add("Lorem ipsum dolor sit amet, consectetur adipiscing elit") 15 | f.Add("Sed eget dui libero.\nVivamus tempus, magna nec mollis convallis, ipsum justo tincidunt ligula, ut varius est mi id nisl.\nMorbi commodo turpis risus, nec vehicula leo auctor sit amet.\nUt imperdiet suscipit massa ac vehicula.\nInterdum et malesuada fames ac ante ipsum primis in faucibus.\nPraesent ligula orci, facilisis pulvinar varius eget, iaculis in erat.\nProin pellentesque arcu sed nisl consectetur tristique.\nQuisque tempus blandit dignissim.\nPhasellus dignissim sollicitudin mauris, sed gravida arcu luctus tincidunt.\nNunc rhoncus sed eros vel molestie.\nAenean sodales tortor eu libero rutrum, et lobortis orci scelerisque.\nPraesent sollicitudin, nunc ut consequat commodo, risus velit consectetur nibh, quis pretium nunc elit et erat.") 16 | f.Add("foo\x1b[31;1;44;0;90;105;38;5;12;48;5;226;38;2;10;20;30;48;2;200;100;50mbar") 17 | 18 | f.Fuzz(func(t *testing.T, s string) { 19 | finder, term := fuzzyfinder.NewWithMockedTerminal() 20 | events := []tcell.Event{key(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})} 21 | term.SetEventsV2(events...) 22 | 23 | _, err := finder.Find( 24 | slice, 25 | func(int) string { return slice[0] }, 26 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { return s }), 27 | ) 28 | if !errors.Is(err, fuzzyfinder.ErrAbort) { 29 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /fuzzing_test.go: -------------------------------------------------------------------------------- 1 | //go:build fuzz 2 | // +build fuzz 3 | 4 | package fuzzyfinder_test 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "math/rand" 11 | "os" 12 | "sync" 13 | "testing" 14 | 15 | "github.com/gdamore/tcell/v2" 16 | 17 | fuzz "github.com/google/gofuzz" 18 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 19 | ) 20 | 21 | type fuzzKey struct { 22 | key tcell.Key 23 | name string 24 | } 25 | 26 | var ( 27 | letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789一花二乃三玖四葉五月") 28 | tbkeys = []tcell.Key{ 29 | tcell.KeyCtrlA, 30 | tcell.KeyCtrlB, 31 | tcell.KeyCtrlE, 32 | tcell.KeyCtrlF, 33 | tcell.KeyBackspace, 34 | tcell.KeyTab, 35 | tcell.KeyCtrlJ, 36 | tcell.KeyCtrlK, 37 | tcell.KeyCtrlN, 38 | tcell.KeyCtrlP, 39 | tcell.KeyCtrlU, 40 | tcell.KeyCtrlW, 41 | tcell.KeyBackspace2, 42 | tcell.KeyUp, 43 | tcell.KeyDown, 44 | tcell.KeyLeft, 45 | tcell.KeyRight, 46 | } 47 | keyMap = map[tcell.Key]string{ 48 | tcell.KeyCtrlA: "A", 49 | tcell.KeyCtrlB: "B", 50 | tcell.KeyCtrlE: "E", 51 | tcell.KeyCtrlF: "F", 52 | tcell.KeyBackspace: "backspace", 53 | tcell.KeyTab: "tab", 54 | tcell.KeyCtrlJ: "J", 55 | tcell.KeyCtrlK: "K", 56 | tcell.KeyCtrlN: "N", 57 | tcell.KeyCtrlP: "P", 58 | tcell.KeyCtrlU: "U", 59 | tcell.KeyCtrlW: "W", 60 | tcell.KeyBackspace2: "backspace2", 61 | tcell.KeyUp: "up", 62 | tcell.KeyDown: "down", 63 | tcell.KeyLeft: "left", 64 | tcell.KeyRight: "right", 65 | } 66 | ) 67 | 68 | var ( 69 | out = flag.String("fuzzout", "fuzz.out", "fuzzing error cases") 70 | hotReload = flag.Bool("hotreload", false, "enable hot-reloading") 71 | numCases = flag.Int("numCases", 30, "number of test cases") 72 | numEvents = flag.Int("numEvents", 10, "number of events") 73 | ) 74 | 75 | // TestFuzz executes fuzzing tests. 76 | // 77 | // Example: 78 | // 79 | // go test -tags fuzz -run TestFuzz -numCases 10 -numEvents 10 80 | // 81 | func TestFuzz(t *testing.T) { 82 | f, err := os.Create(*out) 83 | if err != nil { 84 | t.Fatalf("failed to create a fuzzing output file: %s", err) 85 | } 86 | defer f.Close() 87 | 88 | fuzz := fuzz.New() 89 | 90 | min := func(a, b int) int { 91 | if a < b { 92 | return a 93 | } 94 | return b 95 | } 96 | 97 | for i := 0; i < rand.Intn(*numCases)+10; i++ { 98 | // number of events in tcell.SimulationScreen is limited 10 99 | n := rand.Intn(min(*numEvents, 10)) 100 | events := make([]tcell.Event, n) 101 | for i := 0; i < n; i++ { 102 | if rand.Intn(10) > 3 { 103 | events[i] = ch(letters[rand.Intn(len(letters)-1)]) 104 | } else { 105 | k := tbkeys[rand.Intn(len(tbkeys)-1)] 106 | events[i] = key(input{k, rune(k), tcell.ModNone}) 107 | } 108 | } 109 | 110 | var name string 111 | for _, e := range events { 112 | if e.(*tcell.EventKey).Rune() != 0 { 113 | name += string(e.(*tcell.EventKey).Rune()) 114 | } else { 115 | name += "[" + keyMap[e.(*tcell.EventKey).Key()] + "]" 116 | } 117 | } 118 | 119 | t.Run(name, func(t *testing.T) { 120 | defer func() { 121 | if err := recover(); err != nil { 122 | fmt.Fprintln(f, name) 123 | t.Errorf("panicked: %s", name) 124 | } 125 | return 126 | }() 127 | 128 | var mu sync.Mutex 129 | tracks := tracks 130 | 131 | f, term := fuzzyfinder.NewWithMockedTerminal() 132 | events = append(events, key(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})) 133 | 134 | term.SetEventsV2(events...) 135 | 136 | var ( 137 | iface interface{} 138 | promptStr string 139 | header string 140 | ) 141 | fuzz.Fuzz(&promptStr) 142 | fuzz.Fuzz(&header) 143 | opts := []fuzzyfinder.Option{ 144 | fuzzyfinder.WithPromptString(promptStr), 145 | fuzzyfinder.WithHeader(header), 146 | } 147 | if *hotReload { 148 | iface = &tracks 149 | opts = append(opts, fuzzyfinder.WithHotReload()) 150 | ctx, cancel := context.WithCancel(context.Background()) 151 | defer cancel() 152 | go func() { 153 | for { 154 | select { 155 | case <-ctx.Done(): 156 | return 157 | default: 158 | var t track 159 | fuzz.Fuzz(&t.Name) 160 | fuzz.Fuzz(&t.Artist) 161 | fuzz.Fuzz(&t.Album) 162 | mu.Lock() 163 | tracks = append(tracks, &t) 164 | mu.Unlock() 165 | } 166 | } 167 | }() 168 | } else { 169 | iface = tracks 170 | } 171 | 172 | _, err := f.Find( 173 | iface, 174 | func(i int) string { 175 | mu.Lock() 176 | defer mu.Unlock() 177 | return tracks[i].Name 178 | }, 179 | append( 180 | opts, 181 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 182 | if i == -1 { 183 | return "not found" 184 | } 185 | mu.Lock() 186 | defer mu.Unlock() 187 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 188 | }), 189 | )..., 190 | ) 191 | if err != fuzzyfinder.ErrAbort { 192 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 193 | } 194 | 195 | }) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /fuzzyfinder.go: -------------------------------------------------------------------------------- 1 | // Package fuzzyfinder provides terminal user interfaces for fuzzy-finding. 2 | // 3 | // Note that, all functions are not goroutine-safe. 4 | package fuzzyfinder 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "reflect" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "time" 15 | "unicode" 16 | "unicode/utf8" 17 | 18 | "github.com/gdamore/tcell/v2" 19 | "github.com/ktr0731/go-ansisgr" 20 | "github.com/ktr0731/go-fuzzyfinder/matching" 21 | runewidth "github.com/mattn/go-runewidth" 22 | "github.com/pkg/errors" 23 | ) 24 | 25 | var ( 26 | // ErrAbort is returned from Find* functions if there are no selections. 27 | ErrAbort = errors.New("abort") 28 | errEntered = errors.New("entered") 29 | ) 30 | 31 | // Finds the minimum value among the arguments 32 | func min(vars ...int) int { 33 | min := vars[0] 34 | 35 | for _, i := range vars { 36 | if min > i { 37 | min = i 38 | } 39 | } 40 | 41 | return min 42 | } 43 | 44 | type state struct { 45 | items []string // All item names. 46 | allMatched []matching.Matched // All items. 47 | matched []matching.Matched // Matched items against the input. 48 | 49 | // x is the current index of the prompt line. 50 | x int 51 | // cursorX is the position of prompt line. 52 | // Note that cursorX is the actual width of input runes. 53 | cursorX int 54 | 55 | // The current index of filtered items (matched). 56 | // The initial value is 0. 57 | y int 58 | // cursorY is the position of item line. 59 | // Note that the max size of cursorY depends on max height. 60 | cursorY int 61 | 62 | input []rune 63 | 64 | // selections holds whether a key is selected or not. Each key is 65 | // an index of an item (Matched.Idx). Each value represents the position 66 | // which it is selected. 67 | selection map[int]int 68 | // selectionIdx holds the next index, which is used to a selection's value. 69 | selectionIdx int 70 | } 71 | 72 | type finder struct { 73 | term terminal 74 | stateMu sync.RWMutex 75 | state state 76 | drawTimer *time.Timer 77 | eventCh chan struct{} 78 | opt *opt 79 | 80 | termEventsChan <-chan tcell.Event 81 | } 82 | 83 | func newFinder() *finder { 84 | return &finder{} 85 | } 86 | 87 | func (f *finder) initFinder(items []string, matched []matching.Matched, opt opt) error { 88 | if f.term == nil { 89 | screen, err := tcell.NewScreen() 90 | if err != nil { 91 | return errors.Wrap(err, "failed to new screen") 92 | } 93 | f.term = &termImpl{ 94 | screen: screen, 95 | } 96 | if err := f.term.Init(); err != nil { 97 | return errors.Wrap(err, "failed to initialize screen") 98 | } 99 | 100 | eventsChan := make(chan tcell.Event) 101 | go f.term.ChannelEvents(eventsChan, nil) 102 | f.termEventsChan = eventsChan 103 | } 104 | 105 | f.opt = &opt 106 | f.state = state{} 107 | 108 | var cursorPositioned bool 109 | if opt.multi { 110 | f.state.selection = map[int]int{} 111 | f.state.selectionIdx = 1 112 | 113 | // Apply preselection 114 | for i := range items { 115 | if opt.preselected(i) { 116 | f.state.selection[i] = f.state.selectionIdx 117 | f.state.selectionIdx++ 118 | } 119 | } 120 | } else { 121 | // In non-multi mode, set the cursor position to the first preselected item 122 | for i := range items { 123 | if opt.preselected(i) { 124 | cursorPositioned = true 125 | // Find the matched item index 126 | for j, m := range matched { 127 | if m.Idx == i { 128 | f.state.y = j 129 | f.state.cursorY = min(j, len(matched)-1) 130 | break 131 | } 132 | } 133 | break // Only use the first preselected item 134 | } 135 | } 136 | } 137 | 138 | f.state.items = items 139 | f.state.matched = matched 140 | f.state.allMatched = matched 141 | 142 | // If no preselected item is found and beginAtTop is true, set the cursor to the last item 143 | if !cursorPositioned && opt.beginAtTop { 144 | f.state.cursorY = len(f.state.matched) - 1 145 | f.state.y = len(f.state.matched) - 1 146 | } 147 | 148 | if !isInTesting() { 149 | f.drawTimer = time.AfterFunc(0, func() { 150 | f.stateMu.Lock() 151 | f._draw() 152 | f._drawPreview() 153 | f.stateMu.Unlock() 154 | f.term.Show() 155 | }) 156 | f.drawTimer.Stop() 157 | } 158 | f.eventCh = make(chan struct{}, 30) // A large value 159 | 160 | if opt.query != "" { 161 | f.state.input = []rune(opt.query) 162 | f.state.cursorX = runewidth.StringWidth(opt.query) 163 | f.state.x = len(opt.query) 164 | f.filter() 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func (f *finder) updateItems(items []string, matched []matching.Matched) { 171 | f.stateMu.Lock() 172 | f.state.items = items 173 | f.state.matched = matched 174 | f.state.allMatched = matched 175 | 176 | // Apply preselection to any new items 177 | if f.opt.multi { 178 | for i := 0; i < len(items); i++ { 179 | // Check if this item is not already in the selection and should be preselected 180 | if _, exists := f.state.selection[i]; !exists && f.opt.preselected(i) { 181 | f.state.selection[i] = f.state.selectionIdx 182 | f.state.selectionIdx++ 183 | } 184 | } 185 | } 186 | 187 | f.stateMu.Unlock() 188 | f.eventCh <- struct{}{} 189 | } 190 | 191 | // _draw is used from draw with a timer. 192 | func (f *finder) _draw() { 193 | width, height := f.term.Size() 194 | f.term.Clear() 195 | 196 | maxWidth := width 197 | if f.opt.previewFunc != nil { 198 | maxWidth = width/2 - 1 199 | } 200 | 201 | maxHeight := height 202 | 203 | // prompt line 204 | var promptLinePad int 205 | 206 | for _, r := range f.opt.promptString { 207 | style := tcell.StyleDefault. 208 | Foreground(tcell.ColorBlue). 209 | Background(tcell.ColorDefault) 210 | 211 | f.term.SetContent(promptLinePad, maxHeight-1, r, nil, style) 212 | promptLinePad++ 213 | } 214 | var r rune 215 | var w int 216 | for _, r = range f.state.input { 217 | style := tcell.StyleDefault. 218 | Foreground(tcell.ColorDefault). 219 | Background(tcell.ColorDefault). 220 | Bold(true) 221 | 222 | // Add a space between '>' and runes. 223 | f.term.SetContent(promptLinePad+w, maxHeight-1, r, nil, style) 224 | w += runewidth.RuneWidth(r) 225 | } 226 | f.term.ShowCursor(promptLinePad+f.state.cursorX, maxHeight-1) 227 | 228 | maxHeight-- 229 | 230 | // Header line 231 | if len(f.opt.header) > 0 { 232 | w = 0 233 | for _, r := range runewidth.Truncate(f.opt.header, maxWidth-2, "..") { 234 | style := tcell.StyleDefault. 235 | Foreground(tcell.ColorGreen). 236 | Background(tcell.ColorDefault) 237 | f.term.SetContent(2+w, maxHeight-1, r, nil, style) 238 | w += runewidth.RuneWidth(r) 239 | } 240 | maxHeight-- 241 | } 242 | 243 | // Number line 244 | for i, r := range fmt.Sprintf("%d/%d", len(f.state.matched), len(f.state.items)) { 245 | style := tcell.StyleDefault. 246 | Foreground(tcell.ColorYellow). 247 | Background(tcell.ColorDefault) 248 | 249 | f.term.SetContent(2+i, maxHeight-1, r, nil, style) 250 | } 251 | maxHeight-- 252 | 253 | // Item lines 254 | itemAreaHeight := maxHeight - 1 255 | matched := f.state.matched 256 | offset := f.state.cursorY 257 | y := f.state.y 258 | // From the first (the most bottom) item in the item lines to the end. 259 | matched = matched[y-offset:] 260 | 261 | for i, m := range matched { 262 | if i > itemAreaHeight { 263 | break 264 | } 265 | if i == f.state.cursorY { 266 | style := tcell.StyleDefault. 267 | Foreground(tcell.ColorRed). 268 | Background(tcell.ColorBlack) 269 | 270 | f.term.SetContent(0, maxHeight-1-i, '>', nil, style) 271 | f.term.SetContent(1, maxHeight-1-i, ' ', nil, style) 272 | } 273 | 274 | if f.opt.multi { 275 | if _, ok := f.state.selection[m.Idx]; ok { 276 | style := tcell.StyleDefault. 277 | Foreground(tcell.ColorRed). 278 | Background(tcell.ColorBlack) 279 | 280 | f.term.SetContent(1, maxHeight-1-i, '>', nil, style) 281 | } 282 | } 283 | 284 | var posIdx int 285 | w := 2 286 | for j, r := range []rune(f.state.items[m.Idx]) { 287 | style := tcell.StyleDefault. 288 | Foreground(tcell.ColorDefault). 289 | Background(tcell.ColorDefault) 290 | // Highlight selected strings. 291 | hasHighlighted := false 292 | if posIdx < len(f.state.input) { 293 | from, to := m.Pos[0], m.Pos[1] 294 | if !(from == -1 && to == -1) && (from <= j && j <= to) { 295 | if unicode.ToLower(f.state.input[posIdx]) == unicode.ToLower(r) { 296 | style = tcell.StyleDefault. 297 | Foreground(tcell.ColorGreen). 298 | Background(tcell.ColorDefault) 299 | hasHighlighted = true 300 | posIdx++ 301 | } 302 | } 303 | } 304 | if i == f.state.cursorY { 305 | if hasHighlighted { 306 | style = tcell.StyleDefault. 307 | Foreground(tcell.ColorDarkCyan). 308 | Bold(true). 309 | Background(tcell.ColorBlack) 310 | } else { 311 | style = tcell.StyleDefault. 312 | Foreground(tcell.ColorYellow). 313 | Bold(true). 314 | Background(tcell.ColorBlack) 315 | } 316 | } 317 | 318 | rw := runewidth.RuneWidth(r) 319 | // Shorten item cells. 320 | if w+rw+2 > maxWidth { 321 | f.term.SetContent(w, maxHeight-1-i, '.', nil, style) 322 | f.term.SetContent(w+1, maxHeight-1-i, '.', nil, style) 323 | break 324 | } else { 325 | f.term.SetContent(w, maxHeight-1-i, r, nil, style) 326 | w += rw 327 | } 328 | } 329 | } 330 | } 331 | 332 | func (f *finder) _drawPreview() { 333 | if f.opt.previewFunc == nil { 334 | return 335 | } 336 | 337 | width, height := f.term.Size() 338 | var idx int 339 | if len(f.state.matched) == 0 { 340 | idx = -1 341 | } else { 342 | idx = f.state.matched[f.state.y].Idx 343 | } 344 | 345 | iter := ansisgr.NewIterator(f.opt.previewFunc(idx, width, height)) 346 | 347 | // top line 348 | for i := width / 2; i < width; i++ { 349 | var r rune 350 | switch { 351 | case i == width/2: 352 | r = '┌' 353 | case i == width-1: 354 | r = '┐' 355 | default: 356 | r = '─' 357 | } 358 | 359 | style := tcell.StyleDefault. 360 | Foreground(tcell.ColorBlack). 361 | Background(tcell.ColorDefault) 362 | 363 | f.term.SetContent(i, 0, r, nil, style) 364 | } 365 | // bottom line 366 | for i := width / 2; i < width; i++ { 367 | var r rune 368 | switch { 369 | case i == width/2: 370 | r = '└' 371 | case i == width-1: 372 | r = '┘' 373 | default: 374 | r = '─' 375 | } 376 | 377 | style := tcell.StyleDefault. 378 | Foreground(tcell.ColorBlack). 379 | Background(tcell.ColorDefault) 380 | 381 | f.term.SetContent(i, height-1, r, nil, style) 382 | } 383 | // Start with h=1 to exclude each corner rune. 384 | const vline = '│' 385 | var wvline = runewidth.RuneWidth(vline) 386 | for h := 1; h < height-1; h++ { 387 | // donePreviewLine indicates the preview string of the current line identified by h is already drawn. 388 | var donePreviewLine bool 389 | w := width / 2 390 | for i := width / 2; i < width; i++ { 391 | switch { 392 | // Left vertical line. 393 | case i == width/2: 394 | style := tcell.StyleDefault. 395 | Foreground(tcell.ColorBlack). 396 | Background(tcell.ColorDefault) 397 | f.term.SetContent(i, h, vline, nil, style) 398 | w += wvline 399 | // Right vertical line. 400 | case i == width-1: 401 | style := tcell.StyleDefault. 402 | Foreground(tcell.ColorBlack). 403 | Background(tcell.ColorDefault) 404 | f.term.SetContent(i, h, vline, nil, style) 405 | w += wvline 406 | // Spaces between left and right vertical lines. 407 | case w == width/2+wvline, w == width-1-wvline: 408 | style := tcell.StyleDefault. 409 | Foreground(tcell.ColorDefault). 410 | Background(tcell.ColorDefault) 411 | 412 | f.term.SetContent(w, h, ' ', nil, style) 413 | w++ 414 | default: // Preview text 415 | if donePreviewLine { 416 | continue 417 | } 418 | 419 | r, rstyle, ok := iter.Next() 420 | if !ok || r == '\n' { 421 | // Consumed all preview characters. 422 | donePreviewLine = true 423 | continue 424 | } 425 | 426 | rw := runewidth.RuneWidth(r) 427 | if w+rw > width-1-2 { 428 | donePreviewLine = true 429 | 430 | // Discard the rest of the current line. 431 | consumeIterator(iter, '\n') 432 | 433 | style := tcell.StyleDefault. 434 | Foreground(tcell.ColorDefault). 435 | Background(tcell.ColorDefault) 436 | 437 | f.term.SetContent(w, h, '.', nil, style) 438 | f.term.SetContent(w+1, h, '.', nil, style) 439 | 440 | w += 2 441 | continue 442 | } 443 | 444 | style := tcell.StyleDefault 445 | if color, ok := rstyle.Foreground(); ok { 446 | switch color.Mode() { 447 | case ansisgr.Mode16: 448 | style = style.Foreground(tcell.PaletteColor(color.Value() - 30)) 449 | case ansisgr.Mode256: 450 | style = style.Foreground(tcell.PaletteColor(color.Value())) 451 | case ansisgr.ModeRGB: 452 | r, g, b := color.RGB() 453 | style = style.Foreground(tcell.NewRGBColor(int32(r), int32(g), int32(b))) 454 | } 455 | } 456 | if color, valid := rstyle.Background(); valid { 457 | switch color.Mode() { 458 | case ansisgr.Mode16: 459 | style = style.Background(tcell.PaletteColor(color.Value() - 40)) 460 | case ansisgr.Mode256: 461 | style = style.Background(tcell.PaletteColor(color.Value())) 462 | case ansisgr.ModeRGB: 463 | r, g, b := color.RGB() 464 | style = style.Background(tcell.NewRGBColor(int32(r), int32(g), int32(b))) 465 | } 466 | } 467 | 468 | style = style. 469 | Bold(rstyle.Bold()). 470 | Dim(rstyle.Dim()). 471 | Italic(rstyle.Italic()). 472 | Underline(rstyle.Underline()). 473 | Blink(rstyle.Blink()). 474 | Reverse(rstyle.Reverse()). 475 | StrikeThrough(rstyle.Strikethrough()) 476 | f.term.SetContent(w, h, r, nil, style) 477 | w += rw 478 | } 479 | } 480 | } 481 | } 482 | 483 | func (f *finder) draw(d time.Duration) { 484 | f.stateMu.RLock() 485 | defer f.stateMu.RUnlock() 486 | 487 | if isInTesting() { 488 | // Don't use goroutine scheduling. 489 | f._draw() 490 | f._drawPreview() 491 | f.term.Show() 492 | } else { 493 | f.drawTimer.Reset(d) 494 | } 495 | } 496 | 497 | // readKey reads a key input. 498 | // It returns ErrAbort if esc, CTRL-C or CTRL-D keys are inputted, 499 | // errEntered in case of enter key, and a context error when the passed 500 | // context is cancelled. 501 | func (f *finder) readKey(ctx context.Context) error { 502 | f.stateMu.RLock() 503 | prevInputLen := len(f.state.input) 504 | f.stateMu.RUnlock() 505 | defer func() { 506 | f.stateMu.RLock() 507 | currentInputLen := len(f.state.input) 508 | f.stateMu.RUnlock() 509 | if prevInputLen != currentInputLen { 510 | f.eventCh <- struct{}{} 511 | } 512 | }() 513 | 514 | var e tcell.Event 515 | 516 | select { 517 | case ee := <-f.termEventsChan: 518 | e = ee 519 | case <-ctx.Done(): 520 | return ctx.Err() 521 | } 522 | 523 | f.stateMu.Lock() 524 | defer f.stateMu.Unlock() 525 | 526 | _, screenHeight := f.term.Size() 527 | matchedLinesCount := len(f.state.matched) 528 | 529 | // Max number of lines to scroll by using PgUp and PgDn 530 | var pageScrollBy = screenHeight - 3 531 | 532 | switch e := e.(type) { 533 | case *tcell.EventKey: 534 | switch e.Key() { 535 | case tcell.KeyEsc, tcell.KeyCtrlC, tcell.KeyCtrlD: 536 | return ErrAbort 537 | case tcell.KeyBackspace, tcell.KeyBackspace2: 538 | if len(f.state.input) == 0 { 539 | return nil 540 | } 541 | if f.state.x == 0 { 542 | return nil 543 | } 544 | x := f.state.x 545 | f.state.cursorX -= runewidth.RuneWidth(f.state.input[x-1]) 546 | f.state.x-- 547 | f.state.input = append(f.state.input[:x-1], f.state.input[x:]...) 548 | case tcell.KeyDelete: 549 | if f.state.x == len(f.state.input) { 550 | return nil 551 | } 552 | x := f.state.x 553 | 554 | f.state.input = append(f.state.input[:x], f.state.input[x+1:]...) 555 | case tcell.KeyEnter: 556 | return errEntered 557 | case tcell.KeyLeft, tcell.KeyCtrlB: 558 | if f.state.x > 0 { 559 | f.state.cursorX -= runewidth.RuneWidth(f.state.input[f.state.x-1]) 560 | f.state.x-- 561 | } 562 | case tcell.KeyRight, tcell.KeyCtrlF: 563 | if f.state.x < len(f.state.input) { 564 | f.state.cursorX += runewidth.RuneWidth(f.state.input[f.state.x]) 565 | f.state.x++ 566 | } 567 | case tcell.KeyCtrlA, tcell.KeyHome: 568 | f.state.cursorX = 0 569 | f.state.x = 0 570 | case tcell.KeyCtrlE, tcell.KeyEnd: 571 | f.state.cursorX = runewidth.StringWidth(string(f.state.input)) 572 | f.state.x = len(f.state.input) 573 | case tcell.KeyCtrlW: 574 | in := f.state.input[:f.state.x] 575 | inStr := string(in) 576 | pos := strings.LastIndex(strings.TrimRightFunc(inStr, unicode.IsSpace), " ") 577 | if pos == -1 { 578 | f.state.input = []rune{} 579 | f.state.cursorX = 0 580 | f.state.x = 0 581 | return nil 582 | } 583 | pos = utf8.RuneCountInString(inStr[:pos]) 584 | newIn := f.state.input[:pos+1] 585 | f.state.input = newIn 586 | f.state.cursorX = runewidth.StringWidth(string(newIn)) 587 | f.state.x = len(newIn) 588 | case tcell.KeyCtrlU: 589 | f.state.input = f.state.input[f.state.x:] 590 | f.state.cursorX = 0 591 | f.state.x = 0 592 | case tcell.KeyUp, tcell.KeyCtrlK, tcell.KeyCtrlP: 593 | if f.state.y+1 < matchedLinesCount { 594 | f.state.y++ 595 | } 596 | if f.state.cursorY+1 < min(matchedLinesCount, screenHeight-2) { 597 | f.state.cursorY++ 598 | } 599 | case tcell.KeyDown, tcell.KeyCtrlJ, tcell.KeyCtrlN: 600 | if f.state.y > 0 { 601 | f.state.y-- 602 | } 603 | if f.state.cursorY-1 >= 0 { 604 | f.state.cursorY-- 605 | } 606 | case tcell.KeyPgUp: 607 | f.state.y += min(pageScrollBy, matchedLinesCount-1-f.state.y) 608 | maxCursorY := min(screenHeight-3, matchedLinesCount-1) 609 | f.state.cursorY += min(pageScrollBy, maxCursorY-f.state.cursorY) 610 | case tcell.KeyPgDn: 611 | f.state.y -= min(pageScrollBy, f.state.y) 612 | f.state.cursorY -= min(pageScrollBy, f.state.cursorY) 613 | case tcell.KeyTab: 614 | if !f.opt.multi { 615 | return nil 616 | } 617 | idx := f.state.matched[f.state.y].Idx 618 | if _, ok := f.state.selection[idx]; ok { 619 | delete(f.state.selection, idx) 620 | } else { 621 | f.state.selection[idx] = f.state.selectionIdx 622 | f.state.selectionIdx++ 623 | } 624 | if f.state.y > 0 { 625 | f.state.y-- 626 | } 627 | if f.state.cursorY > 0 { 628 | f.state.cursorY-- 629 | } 630 | default: 631 | if e.Rune() != 0 { 632 | width, _ := f.term.Size() 633 | maxLineWidth := width - 2 - 1 634 | if len(f.state.input)+1 > maxLineWidth { 635 | // Discard inputted rune. 636 | return nil 637 | } 638 | 639 | x := f.state.x 640 | f.state.input = append(f.state.input[:x], append([]rune{e.Rune()}, f.state.input[x:]...)...) 641 | f.state.cursorX += runewidth.RuneWidth(e.Rune()) 642 | f.state.x++ 643 | } 644 | } 645 | case *tcell.EventResize: 646 | f.term.Clear() 647 | 648 | width, height := f.term.Size() 649 | itemAreaHeight := height - 2 - 1 650 | if itemAreaHeight >= 0 && f.state.cursorY > itemAreaHeight { 651 | f.state.cursorY = itemAreaHeight 652 | } 653 | 654 | maxLineWidth := width - 2 - 1 655 | if maxLineWidth < 0 { 656 | f.state.input = nil 657 | f.state.cursorX = 0 658 | f.state.x = 0 659 | } else if len(f.state.input)+1 > maxLineWidth { 660 | // Discard inputted rune. 661 | f.state.input = f.state.input[:maxLineWidth] 662 | f.state.cursorX = runewidth.StringWidth(string(f.state.input)) 663 | f.state.x = maxLineWidth 664 | } 665 | } 666 | return nil 667 | } 668 | 669 | func (f *finder) filter() { 670 | f.stateMu.RLock() 671 | if len(f.state.input) == 0 { 672 | f.stateMu.RUnlock() 673 | f.stateMu.Lock() 674 | defer f.stateMu.Unlock() 675 | f.state.matched = f.state.allMatched 676 | return 677 | } 678 | 679 | // TODO: If input is not delete operation, it is able to 680 | // reduce total iteration. 681 | // FindAll may take a lot of time, so it is desired to use RLock to avoid goroutine blocking. 682 | matchedItems := matching.FindAll(string(f.state.input), f.state.items, matching.WithMode(matching.Mode(f.opt.mode))) 683 | f.stateMu.RUnlock() 684 | 685 | f.stateMu.Lock() 686 | defer f.stateMu.Unlock() 687 | f.state.matched = matchedItems 688 | if len(f.state.matched) == 0 { 689 | f.state.cursorY = 0 690 | f.state.y = 0 691 | return 692 | } 693 | 694 | // If we are in single-select mode, try to move cursor to the first preselected item 695 | // that's still in the matched results 696 | if !f.opt.multi { 697 | for i, m := range f.state.matched { 698 | if f.opt.preselected(m.Idx) { 699 | f.state.y = i 700 | f.state.cursorY = min(i, len(f.state.matched)-1) 701 | return 702 | } 703 | } 704 | } 705 | 706 | switch { 707 | case f.state.cursorY >= len(f.state.matched): 708 | f.state.cursorY = len(f.state.matched) - 1 709 | f.state.y = len(f.state.matched) - 1 710 | case f.state.y >= len(f.state.matched): 711 | f.state.y = len(f.state.matched) - 1 712 | } 713 | } 714 | 715 | func (f *finder) find(slice interface{}, itemFunc func(i int) string, opts []Option) ([]int, error) { 716 | if itemFunc == nil { 717 | return nil, errors.New("itemFunc must not be nil") 718 | } 719 | 720 | opt := defaultOption 721 | for _, o := range opts { 722 | o(&opt) 723 | } 724 | 725 | rv := reflect.ValueOf(slice) 726 | if opt.hotReload && (rv.Kind() != reflect.Ptr || reflect.Indirect(rv).Kind() != reflect.Slice) { 727 | return nil, errors.Errorf("the first argument must be a pointer to a slice, but got %T", slice) 728 | } else if !opt.hotReload && rv.Kind() != reflect.Slice { 729 | return nil, errors.Errorf("the first argument must be a slice, but got %T", slice) 730 | } 731 | 732 | makeItems := func(sliceLen int) ([]string, []matching.Matched) { 733 | items := make([]string, sliceLen) 734 | matched := make([]matching.Matched, sliceLen) 735 | for i := 0; i < sliceLen; i++ { 736 | items[i] = itemFunc(i) 737 | matched[i] = matching.Matched{Idx: i} //nolint:exhaustivestruct 738 | } 739 | return items, matched 740 | } 741 | 742 | var ( 743 | items []string 744 | matched []matching.Matched 745 | ) 746 | 747 | var parentContext context.Context 748 | if opt.context != nil { 749 | parentContext = opt.context 750 | } else { 751 | parentContext = context.Background() 752 | } 753 | 754 | ctx, cancel := context.WithCancel(parentContext) 755 | defer cancel() 756 | 757 | inited := make(chan struct{}) 758 | if opt.hotReload && rv.Kind() == reflect.Ptr { 759 | opt.hotReloadLock.Lock() 760 | rvv := reflect.Indirect(rv) 761 | items, matched = makeItems(rvv.Len()) 762 | opt.hotReloadLock.Unlock() 763 | 764 | go func() { 765 | <-inited 766 | 767 | var prev int 768 | for { 769 | select { 770 | case <-ctx.Done(): 771 | return 772 | case <-time.After(30 * time.Millisecond): 773 | opt.hotReloadLock.Lock() 774 | curr := rvv.Len() 775 | if prev != curr { 776 | items, matched = makeItems(curr) 777 | f.updateItems(items, matched) 778 | } 779 | opt.hotReloadLock.Unlock() 780 | prev = curr 781 | } 782 | } 783 | }() 784 | } else { 785 | items, matched = makeItems(rv.Len()) 786 | } 787 | 788 | if err := f.initFinder(items, matched, opt); err != nil { 789 | return nil, errors.Wrap(err, "failed to initialize the fuzzy finder") 790 | } 791 | 792 | if !isInTesting() { 793 | defer f.term.Fini() 794 | } 795 | 796 | close(inited) 797 | 798 | if opt.selectOne && len(f.state.matched) == 1 { 799 | return []int{f.state.matched[0].Idx}, nil 800 | } 801 | 802 | go func() { 803 | for { 804 | select { 805 | case <-ctx.Done(): 806 | return 807 | case <-f.eventCh: 808 | f.filter() 809 | f.draw(0) 810 | } 811 | } 812 | }() 813 | 814 | for { 815 | select { 816 | case <-ctx.Done(): 817 | return nil, ctx.Err() 818 | default: 819 | f.draw(10 * time.Millisecond) 820 | 821 | err := f.readKey(ctx) 822 | // hack for earning time to filter exec 823 | if isInTesting() { 824 | time.Sleep(50 * time.Millisecond) 825 | } 826 | switch { 827 | case errors.Is(err, ErrAbort): 828 | return nil, ErrAbort 829 | case errors.Is(err, errEntered): 830 | f.stateMu.RLock() 831 | defer f.stateMu.RUnlock() 832 | 833 | if len(f.state.matched) == 0 { 834 | return nil, ErrAbort 835 | } 836 | if f.opt.multi { 837 | if len(f.state.selection) == 0 { 838 | return []int{f.state.matched[f.state.y].Idx}, nil 839 | } 840 | poss, idxs := make([]int, 0, len(f.state.selection)), make([]int, 0, len(f.state.selection)) 841 | for idx, pos := range f.state.selection { 842 | idxs = append(idxs, idx) 843 | poss = append(poss, pos) 844 | } 845 | sort.Slice(idxs, func(i, j int) bool { 846 | return poss[i] < poss[j] 847 | }) 848 | return idxs, nil 849 | } 850 | return []int{f.state.matched[f.state.y].Idx}, nil 851 | case err != nil: 852 | return nil, errors.Wrap(err, "failed to read a key") 853 | } 854 | } 855 | } 856 | } 857 | 858 | // Find displays a UI that provides fuzzy finding against the provided slice. 859 | // The argument slice must be of a slice type. If not, Find returns 860 | // an error. itemFunc is called by the length of slice. previewFunc is called 861 | // when the cursor which points to the currently selected item is changed. 862 | // If itemFunc is nil, Find returns an error. 863 | // 864 | // itemFunc receives an argument i, which is the index of the item currently 865 | // selected. 866 | // 867 | // Find returns ErrAbort if a call to Find is finished with no selection. 868 | func Find(slice interface{}, itemFunc func(i int) string, opts ...Option) (int, error) { 869 | f := newFinder() 870 | return f.Find(slice, itemFunc, opts...) 871 | } 872 | 873 | func (f *finder) Find(slice interface{}, itemFunc func(i int) string, opts ...Option) (int, error) { 874 | res, err := f.find(slice, itemFunc, opts) 875 | 876 | if err != nil { 877 | return 0, err 878 | } 879 | return res[0], err 880 | } 881 | 882 | // FindMulti is nearly the same as Find. The only difference from Find is that 883 | // the user can select multiple items at once, by using the tab key. 884 | func FindMulti(slice interface{}, itemFunc func(i int) string, opts ...Option) ([]int, error) { 885 | f := newFinder() 886 | return f.FindMulti(slice, itemFunc, opts...) 887 | } 888 | 889 | func (f *finder) FindMulti(slice interface{}, itemFunc func(i int) string, opts ...Option) ([]int, error) { 890 | opts = append(opts, withMulti()) 891 | res, err := f.find(slice, itemFunc, opts) 892 | return res, err 893 | } 894 | 895 | func isInTesting() bool { 896 | return flag.Lookup("test.v") != nil 897 | } 898 | 899 | func consumeIterator(iter *ansisgr.Iterator, r rune) { 900 | for { 901 | r, _, ok := iter.Next() 902 | if !ok || r == '\n' { 903 | return 904 | } 905 | } 906 | } 907 | -------------------------------------------------------------------------------- /fuzzyfinder_test.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder_test 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | "sync" 13 | "testing" 14 | "time" 15 | 16 | "github.com/gdamore/tcell/v2" 17 | "github.com/google/go-cmp/cmp" 18 | fuzzyfinder "github.com/ktr0731/go-fuzzyfinder" 19 | "github.com/pkg/errors" 20 | ) 21 | 22 | var ( 23 | update = flag.Bool("update", false, "update golden files") 24 | real = flag.Bool("real", false, "display the actual layout to the terminal") 25 | ) 26 | 27 | func init() { 28 | testing.Init() 29 | flag.Parse() 30 | if *update { 31 | if err := os.RemoveAll(filepath.Join("testdata", "fixtures")); err != nil { 32 | log.Fatalf("RemoveAll should not return an error, but got '%s'", err) 33 | } 34 | if err := os.MkdirAll(filepath.Join("testdata", "fixtures"), 0755); err != nil { 35 | log.Fatalf("MkdirAll should not return an error, but got '%s'", err) 36 | } 37 | } 38 | } 39 | 40 | func assertWithGolden(t *testing.T, f func(t *testing.T) string) { 41 | name := t.Name() 42 | r := strings.NewReplacer( 43 | "/", "-", 44 | " ", "_", 45 | "=", "-", 46 | "'", "", 47 | `"`, "", 48 | ",", "", 49 | ) 50 | normalizeFilename := func(name string) string { 51 | fname := r.Replace(strings.ToLower(name)) + ".golden" 52 | return filepath.Join("testdata", "fixtures", fname) 53 | } 54 | 55 | actual := f(t) 56 | 57 | fname := normalizeFilename(name) 58 | 59 | if *update { 60 | if err := ioutil.WriteFile(fname, []byte(actual), 0600); err != nil { 61 | t.Fatalf("failed to update the golden file: %s", err) 62 | } 63 | return 64 | } 65 | 66 | // Load the golden file. 67 | b, err := ioutil.ReadFile(fname) 68 | if err != nil { 69 | t.Fatalf("failed to load a golden file: %s", err) 70 | } 71 | expected := string(b) 72 | if runtime.GOOS == "windows" { 73 | expected = strings.ReplaceAll(expected, "\r\n", "\n") 74 | } 75 | 76 | if diff := cmp.Diff(expected, actual); diff != "" { 77 | t.Errorf("wrong result: \n%s", diff) 78 | } 79 | } 80 | 81 | type track struct { 82 | Name string 83 | Artist string 84 | Album string 85 | } 86 | 87 | var tracks = []*track{ 88 | {"あの日自分が出て行ってやっつけた時のことをまだ覚えている人の為に", "", ""}, 89 | {"ヒトリノ夜", "ポルノグラフィティ", "ロマンチスト・エゴイスト"}, 90 | {"adrenaline!!!", "TrySail", "TAILWIND"}, 91 | {"ソラニン", "ASIAN KUNG-FU GENERATION", "ソラニン"}, 92 | {"closing", "AQUAPLUS", "WHITE ALBUM2"}, 93 | {"glow", "keeno", "in the rain"}, 94 | {"メーベル", "バルーン", "Corridor"}, 95 | {"ICHIDAIJI", "ポルカドットスティングレイ", "一大事"}, 96 | {"Catch the Moment", "LiSA", "Catch the Moment"}, 97 | } 98 | 99 | func TestReal(t *testing.T) { 100 | if !*real { 101 | t.Skip("--real is disabled") 102 | return 103 | } 104 | _, err := fuzzyfinder.Find( 105 | tracks, 106 | func(i int) string { 107 | return tracks[i].Name 108 | }, 109 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 110 | if i == -1 { 111 | return "not found" 112 | } 113 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 114 | }), 115 | ) 116 | if err != nil { 117 | t.Fatalf("err is not nil: %s", err) 118 | } 119 | } 120 | 121 | func TestFind(t *testing.T) { 122 | t.Parallel() 123 | 124 | cases := map[string]struct { 125 | events []tcell.Event 126 | opts []fuzzyfinder.Option 127 | }{ 128 | "initial": {}, 129 | "input lo": {events: runes("lo")}, 130 | "input glow": {events: runes("glow")}, 131 | "arrow up-down": { 132 | events: keys([]input{ 133 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 134 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 135 | {tcell.KeyDown, rune(tcell.KeyDown), tcell.ModNone}, 136 | }...)}, 137 | "arrow left-right": { 138 | events: append(runes("ゆるふわ樹海"), keys([]input{ 139 | {tcell.KeyLeft, rune(tcell.KeyLeft), tcell.ModNone}, 140 | {tcell.KeyLeft, rune(tcell.KeyLeft), tcell.ModNone}, 141 | {tcell.KeyRight, rune(tcell.KeyRight), tcell.ModNone}, 142 | }...)...), 143 | }, 144 | "backspace": { 145 | events: append(runes("adr .-"), keys([]input{ 146 | {tcell.KeyBackspace, rune(tcell.KeyBackspace), tcell.ModNone}, 147 | {tcell.KeyBackspace, rune(tcell.KeyBackspace), tcell.ModNone}, 148 | }...)...), 149 | }, 150 | "backspace empty": {events: keys(input{tcell.KeyBackspace2, rune(tcell.KeyBackspace2), tcell.ModNone})}, 151 | "backspace2": { 152 | events: append(runes("オレンジ"), keys([]input{ 153 | {tcell.KeyBackspace2, rune(tcell.KeyBackspace2), tcell.ModNone}, 154 | {tcell.KeyBackspace2, rune(tcell.KeyBackspace2), tcell.ModNone}, 155 | }...)...), 156 | }, 157 | "arrow left backspace": { 158 | events: append(runes("オレンジ"), keys([]input{ 159 | {tcell.KeyLeft, rune(tcell.KeyLeft), tcell.ModNone}, 160 | {tcell.KeyBackspace, rune(tcell.KeyBackspace), tcell.ModNone}, 161 | }...)...), 162 | }, 163 | "delete": { 164 | events: append(runes("オレンジ"), keys([]input{ 165 | {tcell.KeyCtrlA, 'A', tcell.ModCtrl}, 166 | {tcell.KeyDelete, rune(tcell.KeyDelete), tcell.ModNone}, 167 | }...)...), 168 | }, 169 | "delete empty": { 170 | events: keys([]input{ 171 | {tcell.KeyCtrlA, 'A', tcell.ModCtrl}, 172 | {tcell.KeyDelete, rune(tcell.KeyDelete), tcell.ModNone}, 173 | }...), 174 | }, 175 | "ctrl-e": { 176 | events: append(runes("恋をしたのは"), keys([]input{ 177 | {tcell.KeyCtrlA, 'A', tcell.ModCtrl}, 178 | {tcell.KeyCtrlE, 'E', tcell.ModCtrl}, 179 | }...)...), 180 | }, 181 | "ctrl-w": {events: append(runes("ハロ / ハワユ"), keys(input{tcell.KeyCtrlW, 'W', tcell.ModCtrl})...)}, 182 | "ctrl-w empty": {events: keys(input{tcell.KeyCtrlW, 'W', tcell.ModCtrl})}, 183 | "ctrl-u": { 184 | events: append(runes("恋をしたのは"), keys([]input{ 185 | {tcell.KeyLeft, rune(tcell.KeyLeft), tcell.ModNone}, 186 | {tcell.KeyCtrlU, 'U', tcell.ModCtrl}, 187 | {tcell.KeyRight, rune(tcell.KeyRight), tcell.ModNone}, 188 | }...)...), 189 | }, 190 | "pg-up": { 191 | events: keys([]input{ 192 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 193 | }...), 194 | }, 195 | "pg-up twice": { 196 | events: keys([]input{ 197 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 198 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 199 | }...), 200 | }, 201 | "pg-dn": { 202 | events: keys([]input{ 203 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 204 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 205 | {tcell.KeyPgDn, rune(tcell.KeyPgDn), tcell.ModNone}, 206 | }...), 207 | }, 208 | "pg-dn twice": { 209 | events: keys([]input{ 210 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 211 | {tcell.KeyPgUp, rune(tcell.KeyPgUp), tcell.ModNone}, 212 | {tcell.KeyPgDn, rune(tcell.KeyPgDn), tcell.ModNone}, 213 | {tcell.KeyPgDn, rune(tcell.KeyPgDn), tcell.ModNone}, 214 | }...), 215 | }, 216 | "long item": { 217 | events: keys([]input{ 218 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 219 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 220 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 221 | }...), 222 | }, 223 | "paging": { 224 | events: keys([]input{ 225 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 226 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 227 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 228 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 229 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 230 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 231 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 232 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 233 | }...), 234 | }, 235 | "tab doesn't work": {events: keys(input{tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone})}, 236 | "backspace doesnt change x if cursorX is 0": { 237 | events: append(runes("a"), keys([]input{ 238 | {tcell.KeyCtrlA, 'A', tcell.ModCtrl}, 239 | {tcell.KeyBackspace, rune(tcell.KeyBackspace), tcell.ModNone}, 240 | {tcell.KeyCtrlF, 'F', tcell.ModCtrl}, 241 | }...)...), 242 | }, 243 | "cursor begins at top": {opts: []fuzzyfinder.Option{fuzzyfinder.WithCursorPosition(fuzzyfinder.CursorPositionTop)}}, 244 | "header line": {opts: []fuzzyfinder.Option{fuzzyfinder.WithHeader("Search?")}}, 245 | "header line which exceeds max charaters": {opts: []fuzzyfinder.Option{fuzzyfinder.WithHeader("Waht do you want to search for?")}}, 246 | } 247 | 248 | for name, c := range cases { 249 | c := c 250 | 251 | t.Run(name, func(t *testing.T) { 252 | t.Parallel() 253 | 254 | events := c.events 255 | 256 | f, term := fuzzyfinder.NewWithMockedTerminal() 257 | events = append(events, key(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})) 258 | term.SetEventsV2(events...) 259 | 260 | opts := append( 261 | c.opts, 262 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 263 | if i == -1 { 264 | return "not found" 265 | } 266 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 267 | }), 268 | fuzzyfinder.WithMode(fuzzyfinder.ModeCaseSensitive), 269 | ) 270 | 271 | assertWithGolden(t, func(t *testing.T) string { 272 | _, err := f.Find( 273 | tracks, 274 | func(i int) string { 275 | return tracks[i].Name 276 | }, 277 | opts..., 278 | ) 279 | if !errors.Is(err, fuzzyfinder.ErrAbort) { 280 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 281 | } 282 | 283 | res := term.GetResult() 284 | return res 285 | }) 286 | }) 287 | } 288 | } 289 | 290 | func TestFind_hotReload(t *testing.T) { 291 | t.Parallel() 292 | 293 | f, term := fuzzyfinder.NewWithMockedTerminal() 294 | events := append(runes("adrena"), keys(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})...) 295 | term.SetEventsV2(events...) 296 | 297 | var mu sync.Mutex 298 | assertWithGolden(t, func(t *testing.T) string { 299 | _, err := f.Find( 300 | &tracks, 301 | func(i int) string { 302 | mu.Lock() 303 | defer mu.Unlock() 304 | return tracks[i].Name 305 | }, 306 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 307 | // Hack, wait until updateItems is called. 308 | time.Sleep(50 * time.Millisecond) 309 | mu.Lock() 310 | defer mu.Unlock() 311 | if i == -1 { 312 | return "not found" 313 | } 314 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 315 | }), 316 | fuzzyfinder.WithMode(fuzzyfinder.ModeCaseSensitive), 317 | fuzzyfinder.WithHotReload(), 318 | ) 319 | if !errors.Is(err, fuzzyfinder.ErrAbort) { 320 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 321 | } 322 | 323 | res := term.GetResult() 324 | return res 325 | }) 326 | } 327 | 328 | func TestFind_hotReloadLock(t *testing.T) { 329 | t.Parallel() 330 | 331 | f, term := fuzzyfinder.NewWithMockedTerminal() 332 | events := append(runes("adrena"), keys(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})...) 333 | term.SetEventsV2(events...) 334 | 335 | var mu sync.RWMutex 336 | assertWithGolden(t, func(t *testing.T) string { 337 | _, err := f.Find( 338 | &tracks, 339 | func(i int) string { 340 | return tracks[i].Name 341 | }, 342 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 343 | // Hack, wait until updateItems is called. 344 | time.Sleep(50 * time.Millisecond) 345 | mu.RLock() 346 | defer mu.RUnlock() 347 | if i == -1 { 348 | return "not found" 349 | } 350 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 351 | }), 352 | fuzzyfinder.WithMode(fuzzyfinder.ModeCaseSensitive), 353 | fuzzyfinder.WithHotReloadLock(mu.RLocker()), 354 | ) 355 | if !errors.Is(err, fuzzyfinder.ErrAbort) { 356 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 357 | } 358 | 359 | res := term.GetResult() 360 | return res 361 | }) 362 | } 363 | 364 | func TestFind_enter(t *testing.T) { 365 | t.Parallel() 366 | 367 | cases := map[string]struct { 368 | events []tcell.Event 369 | expected int 370 | }{ 371 | "initial": {events: keys(input{tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}), expected: 0}, 372 | "mode smart to case-sensitive": {events: runes("JI"), expected: 7}, 373 | } 374 | 375 | for name, c := range cases { 376 | c := c 377 | 378 | t.Run(name, func(t *testing.T) { 379 | t.Parallel() 380 | 381 | events := c.events 382 | 383 | f, term := fuzzyfinder.NewWithMockedTerminal() 384 | events = append(events, key(input{tcell.KeyEnter, rune(tcell.KeyEnter), tcell.ModNone})) 385 | term.SetEventsV2(events...) 386 | 387 | idx, err := f.Find( 388 | tracks, 389 | func(i int) string { 390 | return tracks[i].Name 391 | }, 392 | ) 393 | 394 | if err != nil { 395 | t.Fatalf("Find must not return an error, but got '%s'", err) 396 | } 397 | if idx != c.expected { 398 | t.Errorf("expected index: %d, but got %d", c.expected, idx) 399 | } 400 | }) 401 | } 402 | } 403 | 404 | func TestFind_WithPreviewWindow(t *testing.T) { 405 | t.Parallel() 406 | 407 | cases := map[string]struct { 408 | previewString string 409 | }{ 410 | "normal": {previewString: "foo"}, 411 | "multiline": {previewString: "foo\nbar"}, 412 | "overflowed line": {previewString: strings.Repeat("foo", 1000)}, 413 | "SGR": {previewString: "a\x1b[1mb\x1b[0;31mc\x1b[0;42md\x1b[0;38;5;139me\x1b[0;48;5;229mf\x1b[0;38;2;10;200;30mg\x1b[0;48;2;255;200;100mh"}, 414 | "SGR with overflowed line": {previewString: "a\x1b[1mb\x1b[0;31mc\x1b[0;42md\x1b[0;38;5;139me\x1b[0;48;5;229mf\x1b[0;38;2;10;200;30mg\x1b[0;48;2;255;200;100mh\x1b[m" + strings.Repeat("foo", 1000)}, 415 | } 416 | 417 | for name, c := range cases { 418 | c := c 419 | 420 | t.Run(name, func(t *testing.T) { 421 | t.Parallel() 422 | 423 | f, term := fuzzyfinder.NewWithMockedTerminal() 424 | events := []tcell.Event{key(input{tcell.KeyEnter, rune(tcell.KeyEnter), tcell.ModNone})} 425 | term.SetEventsV2(events...) 426 | 427 | assertWithGolden(t, func(t *testing.T) string { 428 | _, err := f.Find( 429 | tracks, 430 | func(i int) string { 431 | return tracks[i].Name 432 | }, 433 | fuzzyfinder.WithPreviewWindow(func(i, w, h int) string { 434 | return c.previewString 435 | }), 436 | ) 437 | 438 | if err != nil { 439 | t.Fatalf("Find must not return an error, but got '%s'", err) 440 | } 441 | 442 | res := term.GetResult() 443 | return res 444 | }) 445 | }) 446 | } 447 | } 448 | 449 | func TestFind_withContext(t *testing.T) { 450 | t.Parallel() 451 | 452 | f, term := fuzzyfinder.NewWithMockedTerminal() 453 | events := append(runes("adrena"), keys(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})...) 454 | term.SetEventsV2(events...) 455 | 456 | cancelledCtx, cancelFunc := context.WithCancel(context.Background()) 457 | cancelFunc() 458 | 459 | assertWithGolden(t, func(t *testing.T) string { 460 | _, err := f.Find( 461 | tracks, 462 | func(i int) string { 463 | return tracks[i].Name 464 | }, 465 | fuzzyfinder.WithContext(cancelledCtx), 466 | ) 467 | if !errors.Is(err, context.Canceled) { 468 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 469 | } 470 | 471 | res := term.GetResult() 472 | return res 473 | }) 474 | } 475 | 476 | func TestFind_WithQuery(t *testing.T) { 477 | t.Parallel() 478 | var ( 479 | things = []string{"one", "three2one"} 480 | thingFunc = func(i int) string { 481 | return things[i] 482 | } 483 | events = append(runes("one"), key(input{tcell.KeyEnter, rune(tcell.KeyEnter), tcell.ModNone})) 484 | ) 485 | 486 | t.Run("no initial query", func(t *testing.T) { 487 | f, term := fuzzyfinder.NewWithMockedTerminal() 488 | term.SetEventsV2(events...) 489 | 490 | assertWithGolden(t, func(t *testing.T) string { 491 | idx, err := f.Find(things, thingFunc) 492 | if err != nil { 493 | t.Fatalf("Find must not return an error, but got '%s'", err) 494 | } 495 | if idx != 0 { 496 | t.Errorf("expected index: 0, but got %d", idx) 497 | } 498 | res := term.GetResult() 499 | return res 500 | }) 501 | }) 502 | 503 | t.Run("has initial query", func(t *testing.T) { 504 | f, term := fuzzyfinder.NewWithMockedTerminal() 505 | term.SetEventsV2(events...) 506 | 507 | assertWithGolden(t, func(t *testing.T) string { 508 | idx, err := f.Find(things, thingFunc, fuzzyfinder.WithQuery("three2")) 509 | 510 | if err != nil { 511 | t.Fatalf("Find must not return an error, but got '%s'", err) 512 | } 513 | if idx != 1 { 514 | t.Errorf("expected index: 1, but got %d", idx) 515 | } 516 | res := term.GetResult() 517 | return res 518 | }) 519 | }) 520 | } 521 | 522 | func TestFind_WithSelectOne(t *testing.T) { 523 | t.Parallel() 524 | 525 | cases := map[string]struct { 526 | things []string 527 | moreOpts []fuzzyfinder.Option 528 | expected int 529 | abort bool 530 | }{ 531 | "only one option": { 532 | things: []string{"one"}, 533 | expected: 0, 534 | }, 535 | "more than one": { 536 | things: []string{"one", "two"}, 537 | abort: true, 538 | }, 539 | "has initial query": { 540 | things: []string{"one", "two"}, 541 | moreOpts: []fuzzyfinder.Option{ 542 | fuzzyfinder.WithQuery("two"), 543 | }, 544 | expected: 1, 545 | }, 546 | } 547 | 548 | for name, c := range cases { 549 | c := c 550 | 551 | t.Run(name, func(t *testing.T) { 552 | t.Parallel() 553 | f, term := fuzzyfinder.NewWithMockedTerminal() 554 | term.SetEventsV2(key(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})) 555 | 556 | assertWithGolden(t, func(t *testing.T) string { 557 | idx, err := f.Find( 558 | c.things, 559 | func(i int) string { 560 | return c.things[i] 561 | }, 562 | append(c.moreOpts, fuzzyfinder.WithSelectOne())..., 563 | ) 564 | if c.abort { 565 | if !errors.Is(err, fuzzyfinder.ErrAbort) { 566 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 567 | } 568 | } else { 569 | if err != nil { 570 | t.Fatalf("Find must not return an error, but got '%s'", err) 571 | } 572 | if idx != c.expected { 573 | t.Errorf("expected index: %d, but got %d", c.expected, idx) 574 | } 575 | } 576 | res := term.GetResult() 577 | return res 578 | }) 579 | }) 580 | } 581 | } 582 | 583 | func TestFind_error(t *testing.T) { 584 | t.Parallel() 585 | 586 | t.Run("not a slice", func(t *testing.T) { 587 | t.Parallel() 588 | 589 | f := fuzzyfinder.New() 590 | _, err := f.Find("", func(i int) string { return "" }) 591 | if err == nil { 592 | t.Error("Find must return an error, but got nil") 593 | } 594 | }) 595 | 596 | t.Run("itemFunc is nil", func(t *testing.T) { 597 | t.Parallel() 598 | 599 | f := fuzzyfinder.New() 600 | _, err := f.Find([]string{}, nil) 601 | if err == nil { 602 | t.Error("Find must return an error, but got nil") 603 | } 604 | }) 605 | } 606 | 607 | func TestFindMulti(t *testing.T) { 608 | t.Parallel() 609 | 610 | cases := map[string]struct { 611 | events []tcell.Event 612 | expected []int 613 | abort bool 614 | }{ 615 | "input glow": {events: runes("glow"), expected: []int{0}}, 616 | "select two items": {events: keys([]input{ 617 | {tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}, 618 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 619 | {tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}, 620 | }...), expected: []int{0, 1}}, 621 | "select two items with another order": {events: keys([]input{ 622 | {tcell.KeyUp, rune(tcell.KeyUp), tcell.ModNone}, 623 | {tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}, 624 | {tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}, 625 | }...), expected: []int{1, 0}}, 626 | "toggle": {events: keys([]input{ 627 | {tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}, 628 | {tcell.KeyTab, rune(tcell.KeyTab), tcell.ModNone}, 629 | }...), expected: []int{0}}, 630 | "empty result": {events: runes("fffffff"), abort: true}, 631 | "resize window": {events: []tcell.Event{ 632 | tcell.NewEventResize(10, 10), 633 | }, expected: []int{0}}, 634 | } 635 | for name, c := range cases { 636 | c := c 637 | 638 | t.Run(name, func(t *testing.T) { 639 | t.Parallel() 640 | 641 | events := c.events 642 | 643 | f, term := fuzzyfinder.NewWithMockedTerminal() 644 | events = append(events, key(input{tcell.KeyEnter, rune(tcell.KeyEnter), tcell.ModNone})) 645 | term.SetEventsV2(events...) 646 | 647 | idxs, err := f.FindMulti( 648 | tracks, 649 | func(i int) string { 650 | return tracks[i].Name 651 | }, 652 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 653 | if i == -1 { 654 | return "not found" 655 | } 656 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 657 | }), 658 | ) 659 | if c.abort { 660 | if !errors.Is(err, fuzzyfinder.ErrAbort) { 661 | t.Fatalf("Find must return ErrAbort, but got '%s'", err) 662 | } 663 | return 664 | } 665 | if err != nil { 666 | t.Fatalf("Find must not return an error, but got '%s'", err) 667 | } 668 | expectedSelectedNum := len(c.expected) 669 | if n := len(idxs); n != expectedSelectedNum { 670 | t.Errorf("expected the number of selected items is %d, but actual %d", expectedSelectedNum, n) 671 | } 672 | }) 673 | } 674 | } 675 | 676 | func BenchmarkFind(b *testing.B) { 677 | b.Run("normal", func(b *testing.B) { 678 | b.ReportAllocs() 679 | b.ResetTimer() 680 | for i := 0; i < b.N; i++ { 681 | f, term := fuzzyfinder.NewWithMockedTerminal() 682 | events := append(runes("adrele!!"), key(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})) 683 | term.SetEventsV2(events...) 684 | 685 | _, err := f.Find( 686 | tracks, 687 | func(i int) string { 688 | return tracks[i].Name 689 | }, 690 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 691 | if i == -1 { 692 | return "not found" 693 | } 694 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 695 | }), 696 | ) 697 | if err != nil { 698 | b.Fatalf("should not return an error, but got '%s'", err) 699 | } 700 | } 701 | }) 702 | 703 | b.Run("hotreload", func(b *testing.B) { 704 | b.ReportAllocs() 705 | b.ResetTimer() 706 | for i := 0; i < b.N; i++ { 707 | f, term := fuzzyfinder.NewWithMockedTerminal() 708 | events := append(runes("adrele!!"), key(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})) 709 | term.SetEventsV2(events...) 710 | 711 | _, err := f.Find( 712 | &tracks, 713 | func(i int) string { 714 | return tracks[i].Name 715 | }, 716 | fuzzyfinder.WithPreviewWindow(func(i, width, height int) string { 717 | if i == -1 { 718 | return "not found" 719 | } 720 | return "Name: " + tracks[i].Name + "\nArtist: " + tracks[i].Artist 721 | }), 722 | fuzzyfinder.WithHotReload(), 723 | ) 724 | if err != nil { 725 | b.Fatalf("should not return an error, but got '%s'", err) 726 | } 727 | } 728 | }) 729 | } 730 | 731 | func runes(s string) []tcell.Event { 732 | r := []rune(s) 733 | e := make([]tcell.Event, 0, len(r)) 734 | for _, r := range r { 735 | e = append(e, ch(r)) 736 | } 737 | return e 738 | } 739 | 740 | func ch(r rune) tcell.Event { 741 | return key(input{tcell.KeyRune, r, tcell.ModNone}) 742 | } 743 | 744 | func key(input input) tcell.Event { 745 | return tcell.NewEventKey(input.key, input.ch, input.mod) 746 | } 747 | 748 | func keys(inputs ...input) []tcell.Event { 749 | k := make([]tcell.Event, 0, len(inputs)) 750 | for _, in := range inputs { 751 | k = append(k, key(in)) 752 | } 753 | return k 754 | } 755 | 756 | type input struct { 757 | key tcell.Key 758 | ch rune 759 | mod tcell.ModMask 760 | } 761 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ktr0731/go-fuzzyfinder 2 | 3 | require ( 4 | github.com/gdamore/tcell/v2 v2.6.0 5 | github.com/google/go-cmp v0.7.0 6 | github.com/google/gofuzz v1.2.0 7 | github.com/ktr0731/go-ansisgr v0.1.0 8 | github.com/mattn/go-runewidth v0.0.16 9 | github.com/nsf/termbox-go v1.1.1 10 | github.com/pkg/errors v0.9.1 11 | ) 12 | 13 | require ( 14 | github.com/gdamore/encoding v1.0.1 // indirect 15 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 16 | github.com/rivo/uniseg v0.4.7 // indirect 17 | golang.org/x/sys v0.32.0 // indirect 18 | golang.org/x/term v0.31.0 // indirect 19 | golang.org/x/text v0.24.0 // indirect 20 | ) 21 | 22 | go 1.24 23 | 24 | toolchain go1.24.1 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 2 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 3 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 4 | github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= 5 | github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= 6 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 7 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 8 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 9 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 10 | github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= 11 | github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= 12 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 13 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 14 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 15 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 16 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 17 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 18 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 19 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 20 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 21 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 22 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 23 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 24 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 25 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 26 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 27 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 28 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 29 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 30 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 31 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 32 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 33 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 34 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 35 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 37 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 38 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 39 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 45 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 46 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 47 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 48 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 49 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 50 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 51 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 52 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 53 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 54 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 55 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 56 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 57 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 58 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 59 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 60 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 61 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 62 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder 2 | 3 | import "github.com/gdamore/tcell/v2" 4 | 5 | func New() *finder { 6 | return &finder{} 7 | } 8 | 9 | func NewWithMockedTerminal() (*finder, *TerminalMock) { 10 | eventsChan := make(chan tcell.Event, 10) 11 | 12 | f := New() 13 | f.termEventsChan = eventsChan 14 | 15 | m := f.UseMockedTerminalV2() 16 | go m.ChannelEvents(eventsChan, nil) 17 | 18 | w, h := 60, 10 // A normally value. 19 | m.SetSize(w, h) 20 | return f, m 21 | } 22 | -------------------------------------------------------------------------------- /matching/matching.go: -------------------------------------------------------------------------------- 1 | // Package matching provides matching features that find appropriate strings 2 | // by using a passed input string. 3 | package matching 4 | 5 | import ( 6 | "sort" 7 | "strings" 8 | "unicode" 9 | 10 | "github.com/ktr0731/go-fuzzyfinder/scoring" 11 | ) 12 | 13 | // Matched represents a result of FindAll. 14 | type Matched struct { 15 | // Idx is the index of an item of the original slice which was used to 16 | // search matched strings. 17 | Idx int 18 | // Pos is the range of matched position. 19 | // [2]int represents an open interval of a position. 20 | Pos [2]int 21 | // score is the value that indicates how it similar to the input string. 22 | // The bigger score, the more similar it is. 23 | score int 24 | } 25 | 26 | // Option represents available matching options. 27 | type Option func(*opt) 28 | 29 | type Mode int 30 | 31 | const ( 32 | ModeSmart Mode = iota 33 | ModeCaseSensitive 34 | ModeCaseInsensitive 35 | ) 36 | 37 | // opt represents available options and its default values. 38 | type opt struct { 39 | mode Mode 40 | } 41 | 42 | // WithMode specifies a matching mode. The default mode is ModeSmart. 43 | func WithMode(m Mode) Option { 44 | return func(o *opt) { 45 | o.mode = m 46 | } 47 | } 48 | 49 | // FindAll tries to find out sub-strings from slice that match the passed argument in. 50 | // The returned slice is sorted by similarity scores in descending order. 51 | func FindAll(in string, slice []string, opts ...Option) []Matched { 52 | var opt opt 53 | for _, o := range opts { 54 | o(&opt) 55 | } 56 | m := match(in, slice, opt) 57 | sort.Slice(m, func(i, j int) bool { 58 | if m[i].score == m[j].score { 59 | return m[i].Idx > m[j].Idx 60 | } 61 | return m[i].score > m[j].score 62 | }) 63 | return m 64 | } 65 | 66 | // match iterates each string of slice for check whether it is matched to the input string. 67 | func match(input string, slice []string, opt opt) (res []Matched) { 68 | if opt.mode == ModeSmart { 69 | // Find an upper-case rune 70 | n := strings.IndexFunc(input, unicode.IsUpper) 71 | if n == -1 { 72 | opt.mode = ModeCaseInsensitive 73 | input = strings.ToLower(input) 74 | } else { 75 | opt.mode = ModeCaseSensitive 76 | } 77 | } 78 | 79 | in := []rune(input) 80 | for idxOfSlice, s := range slice { 81 | var idx int 82 | if opt.mode == ModeCaseInsensitive { 83 | s = strings.ToLower(s) 84 | } 85 | LINE_MATCHING: 86 | for _, r := range s { 87 | if r == in[idx] { 88 | idx++ 89 | if idx == len(in) { 90 | score, pos := scoring.Calculate(s, input) 91 | res = append(res, Matched{ 92 | Idx: idxOfSlice, 93 | Pos: pos, 94 | score: score, 95 | }) 96 | break LINE_MATCHING 97 | } 98 | } 99 | } 100 | } 101 | return res 102 | } 103 | -------------------------------------------------------------------------------- /matching/matching_test.go: -------------------------------------------------------------------------------- 1 | package matching_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/ktr0731/go-fuzzyfinder/matching" 8 | ) 9 | 10 | func TestMatch(t *testing.T) { 11 | t.Parallel() 12 | 13 | cases := map[string]struct { 14 | idx int 15 | in string 16 | expected string // If expected is empty, it means there are no matched strings. 17 | caseSensitive bool 18 | }{ 19 | "normal": {idx: 2, in: "ink now", expected: "inkle Snow"}, 20 | "case sensitive": {idx: 1, in: "SOUNDNY", expected: "SOUND OF DESTINY", caseSensitive: true}, 21 | "case sensitive2": {idx: 0, in: "white um", caseSensitive: true}, 22 | } 23 | slice := []string{ 24 | "WHITE ALBUM", 25 | "SOUND OF DESTINY", 26 | "Twinkle Snow", 27 | } 28 | for name, c := range cases { 29 | c := c 30 | t.Run(name, func(t *testing.T) { 31 | t.Parallel() 32 | 33 | var matched []matching.Matched 34 | if c.caseSensitive { 35 | matched = matching.FindAll(c.in, slice, matching.WithMode(matching.ModeCaseSensitive)) 36 | } else { 37 | matched = matching.FindAll(c.in, slice) 38 | } 39 | n := len(matched) 40 | if c.expected == "" { 41 | if n != 0 { 42 | t.Errorf("the result length must be 0, but got %d", n) 43 | } 44 | return 45 | } 46 | 47 | if n != 1 { 48 | t.Fatalf("the result length must be 1, but got %d", n) 49 | } 50 | m := matched[0] 51 | if m.Idx != c.idx { 52 | t.Errorf("m.Idx must be equal to %d, but got %d", c.idx, m.Idx) 53 | } 54 | from, to := m.Pos[0], m.Pos[1]+1 55 | var actual string 56 | fmt.Println(to, slice[c.idx]) 57 | if to > len(slice[c.idx]) { 58 | actual = slice[c.idx][from:] 59 | } else { 60 | actual = slice[c.idx][from:to] 61 | } 62 | if actual != c.expected { 63 | t.Errorf("invalid range: from = %d, to = %d, content = %s, expected = %s", from, to, slice[2][from:to], c.expected) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func BenchmarkMatch(b *testing.B) { 70 | benchSlice := []string{ 71 | "Lorem ipsum dolor sit amet, consectetuer adipiscing elit", 72 | "Aenean commodo ligula eget dolor", 73 | "Aenean massa", 74 | "Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus", 75 | "Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem", 76 | "Nulla consequat massa quis enim", 77 | "Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu", 78 | "In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo", 79 | "Nullam dictum felis eu pede mollis pretium", 80 | "Integer tincidunt", 81 | "Cras dapibus", 82 | "Vivamus elementum semper nisi", 83 | "Aenean vulputate eleifend tellus", 84 | "Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim", 85 | "Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus", 86 | "Phasellus viverra nulla ut metus varius laoreet", 87 | "Quisque rutrum", 88 | "Aenean imperdiet", 89 | "Etiam ultricies nisi vel augue", 90 | "Curabitur ullamcorper ultricies nisi", 91 | "Nam eget dui", 92 | "Etiam rhoncus", 93 | "Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum", 94 | "Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem", 95 | "Maecenas nec odio et ante tincidunt tempus", 96 | "Donec vitae sapien ut libero venenatis faucibus", 97 | "Nullam quis ante", 98 | "Etiam sit amet orci eget eros faucibus tincidunt", 99 | "Duis leo", 100 | "Sed fringilla mauris sit amet nibh", 101 | "Donec sodales sagittis magna", 102 | "Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero", 103 | "Fusce vulputate eleifend sapien", 104 | "Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus", 105 | "Nullam accumsan lorem in dui", 106 | "Cras ultricies mi eu turpis hendrerit fringilla", 107 | "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia", 108 | "Nam pretium turpis et arcu", 109 | "Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum", 110 | "Sed aliquam ultrices mauris", 111 | "Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris", 112 | "Praesent adipiscing", 113 | "Phasellus ullamcorper ipsum rutrum nunc", 114 | "Nunc nonummy metus", 115 | "Vestibulum volutpat pretium libero", 116 | "Cras id dui", 117 | "Aenean ut eros et nisl sagittis vestibulum", 118 | "Nullam nulla eros, ultricies sit amet, nonummy id, imperdiet feugiat, pede", 119 | "Sed lectus", 120 | "Donec mollis hendrerit risus", 121 | "Phasellus nec sem in justo pellentesque facilisis", 122 | "Etiam imperdiet imperdiet orci", 123 | "Nunc nec neque", 124 | "Phasellus leo dolor, tempus non, auctor et, hendrerit quis, nisi", 125 | "Curabitur ligula sapien, tincidunt non, euismod vitae, posuere imperdiet, leo", 126 | "Maecenas malesuada", 127 | "Praesent congue erat at massa", 128 | "Sed cursus turpis vitae tortor", 129 | "Donec posuere vulputate arcu", 130 | "Phasellus accumsan cursus velit", 131 | "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Sed aliquam, nisi quis porttitor congue, elit erat euismod orci, ac placerat dolor lectus quis orci", 132 | "Phasellus consectetuer vestibulum elit", 133 | "Aenean tellus metus, bibendum sed, posuere ac, mattis non, nunc", 134 | "Vestibulum fringilla pede sit amet augue", 135 | "In turpis", 136 | "Pellentesque posuere", 137 | "Praesent turpis", 138 | "Aenean posuere, tortor sed cursus feugiat, nunc augue blandit nunc, eu sollicitudin urna dolor sagittis lacus", 139 | "Donec elit libero, sodales nec, volutpat a, suscipit non, turpis", 140 | "Nullam sagittis", 141 | "Suspendisse pulvinar, augue ac venenatis condimentum, sem libero volutpat nibh, nec pellentesque velit pede quis nunc", 142 | "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Fusce id purus", 143 | "Ut varius tincidunt libero", 144 | "Phasellus dolor", 145 | "Maecenas vestibulum mollis diam", 146 | "Pellentesque ut neque", 147 | "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas", 148 | "In dui magna, posuere eget, vestibulum et, tempor auctor, justo", 149 | "In ac felis quis tortor malesuada pretium", 150 | "Pellentesque auctor neque nec urna", 151 | "Proin sapien ipsum, porta a, auctor quis, euismod ut, mi", 152 | "Aenean viverra rhoncus pede", 153 | "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas", 154 | "Ut non enim eleifend felis pretium feugiat", 155 | "Vivamus quis mi", 156 | "Phasellus a est", 157 | "Phasellus magna", 158 | "In hac habitasse platea dictumst", 159 | "Curabitur at lacus ac velit ornare lobortis", 160 | "Curabitur a felis in nunc fringilla tristique", 161 | "Morbi mattis ullamcorper velit", 162 | "Phasellus gravida semper nisi", 163 | "Nullam vel sem", 164 | "Pellentesque libero tortor, tincidunt et, tincidunt eget, semper nec, quam", 165 | "Sed hendrerit", 166 | "Morbi ac felis", 167 | "Nunc egestas, augue at pellentesque laoreet, felis eros vehicula leo, at malesuada velit leo quis pede", 168 | "Donec interdum, metus et hendrerit aliquet, dolor diam sagittis ligula, eget egestas libero turpis vel mi", 169 | "Nunc nulla", 170 | "Fusce risus nisl, viverra et, tempor et, pretium in, sapien", 171 | "Donec venenatis vulputate lorem", 172 | "Morbi nec metus", 173 | "Phasellus blandit leo ut odio", 174 | "Maecenas ullamcorper, dui et placerat feugiat, eros pede varius nisi, condimentum viverra felis nunc et lorem", 175 | "Sed magna purus, fermentum eu, tincidunt eu, varius ut, felis", 176 | "In auctor lobortis lacus", 177 | "Quisque libero metus, condimentum nec, tempor a, commodo mollis, magna", 178 | "Vestibulum ullamcorper mauris at ligula", 179 | "Fusce fermentum", 180 | "Nullam cursus lacinia erat", 181 | "Praesent blandit laoreet nibh", 182 | "Fusce convallis metus id felis luctus adipiscing", 183 | "Pellentesque egestas, neque sit amet convallis pulvinar, justo nulla eleifend augue, ac auctor orci leo non est", 184 | "Quisque id mi", 185 | "Ut tincidunt tincidunt erat", 186 | "Etiam feugiat lorem non metus", 187 | "Vestibulum dapibus nunc ac augue", 188 | "Curabitur vestibulum aliquam leo", 189 | "Praesent egestas neque eu enim", 190 | "In hac habitasse platea dictumst", 191 | "Fusce a quam", 192 | "Etiam ut purus mattis mauris sodales aliquam", 193 | "Curabitur nisi", 194 | "Quisque malesuada placerat nisl", 195 | "Nam ipsum risus, rutrum vitae, vestibulum eu, molestie vel, lacus", 196 | "Sed augue ipsum, egestas nec, vestibulum et, malesuada adipiscing, dui", 197 | "Vestibulum facilisis, purus nec pulvinar iaculis, ligula mi congue nunc, vitae euismod ligula urna in dolor", 198 | "Mauris sollicitudin fermentum libero", 199 | "Praesent nonummy mi in odio", 200 | "Nunc interdum lacus sit amet orci", 201 | "Vestibulum rutrum, mi nec elementum vehicula, eros quam gravida nisl, id fringilla neque ante vel mi", 202 | "Morbi mollis tellus ac sapien", 203 | "Phasellus volutpat, metus eget egestas mollis, lacus lacus blandit dui, id egestas quam mauris ut lacus", 204 | "Fusce vel dui", 205 | "Sed in libero ut nibh placerat accumsan", 206 | "Proin faucibus arcu quis ante", 207 | "In consectetuer turpis ut velit", 208 | "Nulla sit amet est", 209 | "Praesent metus tellus, elementum eu, semper a, adipiscing nec, purus", 210 | "Cras risus ipsum, faucibus ut, ullamcorper id, varius ac, leo", 211 | "Suspendisse feugiat", 212 | "Suspendisse enim turpis, dictum sed, iaculis a, condimentum nec, nisi", 213 | "Praesent nec nisl a purus blandit viverra", 214 | "Praesent ac massa at ligula laoreet iaculis", 215 | "Nulla neque dolor, sagittis eget, iaculis quis, molestie non, velit", 216 | "Mauris turpis nunc, blandit et, volutpat molestie, porta ut, ligula", 217 | "Fusce pharetra convallis urna", 218 | "Quisque ut nisi", 219 | "Donec mi odio, faucibus at, scelerisque quis, convallis in, nisi", 220 | "Suspendisse non nisl sit amet velit hendrerit rutrum", 221 | "Ut leo", 222 | "Ut a nisl id ante tempus hendrerit", 223 | "Proin pretium, leo ac pellentesque mollis, felis nunc ultrices eros, sed gravida augue augue mollis justo", 224 | "Suspendisse eu ligula", 225 | "Nulla facilisi", 226 | "Donec id justo", 227 | "Praesent porttitor, nulla vitae posuere iaculis, arcu nisl dignissim dolor, a pretium mi sem ut ipsum", 228 | "Curabitur suscipit suscipit tellus", 229 | "Praesent vestibulum dapibus nibh", 230 | "Etiam iaculis nunc ac metus", 231 | "Ut id nisl quis enim dignissim sagittis", 232 | "Etiam sollicitudin, ipsum eu pulvinar rutrum, tellus ipsum laoreet sapien, quis venenatis ante odio sit amet eros", 233 | "Proin magna", 234 | "Duis vel nibh at velit scelerisque suscipit", 235 | "Curabitur turpis", 236 | "Vestibulum suscipit nulla quis orci", 237 | "Fusce ac felis sit amet ligula pharetra condimentum", 238 | "Maecenas egestas arcu quis ligula mattis placerat", 239 | "Duis lobortis massa imperdiet quam", 240 | "Suspendisse potenti", 241 | "Pellentesque commodo eros a enim", 242 | "Vestibulum turpis sem, aliquet eget, lobortis pellentesque, rutrum eu, nisl", 243 | "Sed libero", 244 | "Aliquam erat volutpat", 245 | "Etiam vitae tortor", 246 | "Morbi vestibulum volutpat enim", 247 | "Aliquam eu nunc", 248 | "Nunc sed turpis", 249 | "Sed mollis, eros et ultrices tempus, mauris ipsum aliquam libero, non adipiscing dolor urna a orci", 250 | "Nulla porta dolor", 251 | "Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos hymenaeos", 252 | "Pellentesque dapibus hendrerit tortor", 253 | "Praesent egestas tristique nibh", 254 | "Sed a libero", 255 | "Cras varius", 256 | "Donec vitae orci sed dolor rutrum auctor", 257 | "Fusce egestas elit eget lorem", 258 | "Suspendisse nisl elit, rhoncus eget, elementum ac, condimentum eget, diam", 259 | "Nam at tortor in tellus interdum sagittis", 260 | "Aliquam lobortis", 261 | "Donec orci lectus, aliquam ut, faucibus non, euismod id, nulla", 262 | "Curabitur blandit mollis lacus", 263 | "Nam adipiscing", 264 | "Vestibulum eu odio", 265 | "Vivamus laoreet", 266 | "Nullam tincidunt adipiscing enim", 267 | "Phasellus tempus", 268 | "Proin viverra, ligula sit amet ultrices semper, ligula arcu tristique sapien, a accumsan nisi mauris ac eros", 269 | "Fusce neque", 270 | "Suspendisse faucibus, nunc et pellentesque egestas, lacus ante convallis tellus, vitae iaculis lacus elit id tortor", 271 | } 272 | in := "cas hr " 273 | 274 | b.ReportAllocs() 275 | 276 | b.Run("case insensitive", func(b *testing.B) { 277 | for i := 0; i < b.N; i++ { 278 | matching.FindAll(in, benchSlice) 279 | } 280 | }) 281 | b.Run("case sensitive", func(b *testing.B) { 282 | for i := 0; i < b.N; i++ { 283 | matching.FindAll(in, benchSlice, matching.WithMode(matching.ModeCaseSensitive)) 284 | } 285 | }) 286 | } 287 | -------------------------------------------------------------------------------- /mock.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | runewidth "github.com/mattn/go-runewidth" 12 | "github.com/nsf/termbox-go" 13 | ) 14 | 15 | type cell struct { 16 | ch rune 17 | bg, fg termbox.Attribute 18 | } 19 | 20 | type simScreen tcell.SimulationScreen 21 | 22 | // TerminalMock is a mocked terminal for testing. 23 | // Most users should use it by calling UseMockedTerminal. 24 | type TerminalMock struct { 25 | simScreen 26 | sizeMu sync.RWMutex 27 | width, height int 28 | 29 | eventsMu sync.Mutex 30 | events []termbox.Event 31 | 32 | cellsMu sync.RWMutex 33 | cells []*cell 34 | 35 | resultMu sync.RWMutex 36 | result string 37 | 38 | sleepDuration time.Duration 39 | v2 bool 40 | } 41 | 42 | // SetSize changes the pseudo-size of the window. 43 | // Note that SetSize resets added cells. 44 | func (m *TerminalMock) SetSize(w, h int) { 45 | if m.v2 { 46 | m.simScreen.SetSize(w, h) 47 | return 48 | } 49 | m.sizeMu.Lock() 50 | defer m.sizeMu.Unlock() 51 | m.cellsMu.Lock() 52 | defer m.cellsMu.Unlock() 53 | m.width = w 54 | m.height = h 55 | m.cells = make([]*cell, w*h) 56 | } 57 | 58 | // Deprecated: Use SetEventsV2 59 | // SetEvents sets all events, which are fetched by pollEvent. 60 | // A user of this must set the EscKey event at the end. 61 | func (m *TerminalMock) SetEvents(events ...termbox.Event) { 62 | m.eventsMu.Lock() 63 | defer m.eventsMu.Unlock() 64 | m.events = events 65 | } 66 | 67 | // SetEventsV2 sets all events, which are fetched by pollEvent. 68 | // A user of this must set the EscKey event at the end. 69 | func (m *TerminalMock) SetEventsV2(events ...tcell.Event) { 70 | for _, event := range events { 71 | switch event := event.(type) { 72 | case *tcell.EventKey: 73 | ek := event 74 | m.simScreen.InjectKey(ek.Key(), ek.Rune(), ek.Modifiers()) 75 | case *tcell.EventResize: 76 | er := event 77 | w, h := er.Size() 78 | m.simScreen.SetSize(w, h) 79 | } 80 | } 81 | } 82 | 83 | // GetResult returns a flushed string that is displayed to the actual terminal. 84 | // It contains all escape sequences such that ANSI escape code. 85 | func (m *TerminalMock) GetResult() string { 86 | if !m.v2 { 87 | m.resultMu.RLock() 88 | defer m.resultMu.RUnlock() 89 | return m.result 90 | } 91 | 92 | var s string 93 | 94 | // set cursor for snapshot test 95 | setCursor := func() { 96 | cursorX, cursorY, _ := m.simScreen.GetCursor() 97 | mainc, _, _, _ := m.simScreen.GetContent(cursorX, cursorY) 98 | if mainc == ' ' { 99 | m.simScreen.SetContent(cursorX, cursorY, '\u2588', nil, tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorDefault)) 100 | } else { 101 | m.simScreen.SetContent(cursorX, cursorY, mainc, nil, tcell.StyleDefault.Background(tcell.ColorWhite)) 102 | } 103 | m.simScreen.Show() 104 | } 105 | 106 | setCursor() 107 | 108 | m.resultMu.Lock() 109 | 110 | cells, width, height := m.simScreen.GetContents() 111 | 112 | for h := 0; h < height; h++ { 113 | prevFg, prevBg := tcell.ColorDefault, tcell.ColorDefault 114 | 115 | for w := 0; w < width; w++ { 116 | cell := cells[h*width+w] 117 | fg, bg, attr := cell.Style.Decompose() 118 | if fg != prevFg || bg != prevBg { 119 | prevFg, prevBg = fg, bg 120 | 121 | s += "\x1b\x5b\x6d" // Reset previous color. 122 | v := parseAttrV2(fg, bg, attr) 123 | s += v 124 | } 125 | 126 | s += string(cell.Runes) 127 | rw := runewidth.RuneWidth(cell.Runes[0]) 128 | if rw != 0 { 129 | w += rw - 1 130 | } 131 | } 132 | s += "\n" 133 | } 134 | s += "\x1b\x5b\x6d" // Reset previous color. 135 | 136 | m.resultMu.Unlock() 137 | 138 | return s 139 | } 140 | 141 | func (m *TerminalMock) init() error { 142 | return nil 143 | } 144 | 145 | func (m *TerminalMock) size() (width int, height int) { 146 | m.sizeMu.RLock() 147 | defer m.sizeMu.RUnlock() 148 | return m.width, m.height 149 | } 150 | 151 | func (m *TerminalMock) clear(fg termbox.Attribute, bg termbox.Attribute) error { 152 | // TODO 153 | return nil 154 | } 155 | 156 | func (m *TerminalMock) setCell(x int, y int, ch rune, fg termbox.Attribute, bg termbox.Attribute) { 157 | m.sizeMu.RLock() 158 | defer m.sizeMu.RUnlock() 159 | m.cellsMu.Lock() 160 | defer m.cellsMu.Unlock() 161 | 162 | if x < 0 || x >= m.width { 163 | return 164 | } 165 | if y < 0 || y >= m.height { 166 | return 167 | } 168 | m.cells[y*m.width+x] = &cell{ch: ch, fg: fg, bg: bg} 169 | } 170 | 171 | func (m *TerminalMock) setCursor(x int, y int) { 172 | m.sizeMu.RLock() 173 | defer m.sizeMu.RUnlock() 174 | m.cellsMu.Lock() 175 | defer m.cellsMu.Unlock() 176 | if x < 0 || x >= m.width { 177 | return 178 | } 179 | if y < 0 || y >= m.height { 180 | return 181 | } 182 | i := y*m.width + x 183 | if m.cells[i] == nil { 184 | m.cells[y*m.width+x] = &cell{ch: '\u2588', fg: termbox.ColorWhite, bg: termbox.ColorDefault} 185 | } else { 186 | // Cursor on a rune. 187 | m.cells[y*m.width+x].bg = termbox.ColorWhite 188 | } 189 | } 190 | 191 | func (m *TerminalMock) pollEvent() termbox.Event { 192 | m.eventsMu.Lock() 193 | defer m.eventsMu.Unlock() 194 | if len(m.events) == 0 { 195 | panic("pollEvent called with empty events. have you set expected events by SetEvents?") 196 | } 197 | e := m.events[0] 198 | m.events = m.events[1:] 199 | // Wait a moment for goroutine scheduling. 200 | time.Sleep(m.sleepDuration) 201 | return e 202 | } 203 | 204 | // flush displays all items with formatted layout. 205 | func (m *TerminalMock) flush() { 206 | m.cellsMu.RLock() 207 | 208 | var s string 209 | for j := 0; j < m.height; j++ { 210 | prevFg, prevBg := termbox.ColorDefault, termbox.ColorDefault 211 | for i := 0; i < m.width; i++ { 212 | c := m.cells[j*m.width+i] 213 | if c == nil { 214 | s += " " 215 | prevFg, prevBg = termbox.ColorDefault, termbox.ColorDefault 216 | continue 217 | } else { 218 | var fgReset bool 219 | if c.fg != prevFg { 220 | s += "\x1b\x5b\x6d" // Reset previous color. 221 | s += parseAttr(c.fg, true) 222 | prevFg = c.fg 223 | prevBg = termbox.ColorDefault 224 | fgReset = true 225 | } 226 | if c.bg != prevBg { 227 | if !fgReset { 228 | s += "\x1b\x5b\x6d" // Reset previous color. 229 | prevFg = termbox.ColorDefault 230 | } 231 | s += parseAttr(c.bg, false) 232 | prevBg = c.bg 233 | } 234 | s += string(c.ch) 235 | rw := runewidth.RuneWidth(c.ch) 236 | if rw != 0 { 237 | i += rw - 1 238 | } 239 | } 240 | } 241 | s += "\n" 242 | } 243 | s += "\x1b\x5b\x6d" // Reset previous color. 244 | 245 | m.cellsMu.RUnlock() 246 | m.cellsMu.Lock() 247 | m.cells = make([]*cell, m.width*m.height) 248 | m.cellsMu.Unlock() 249 | 250 | m.resultMu.Lock() 251 | defer m.resultMu.Unlock() 252 | 253 | m.result = s 254 | } 255 | 256 | func (m *TerminalMock) close() {} 257 | 258 | // UseMockedTerminal switches the terminal, which is used from 259 | // this package to a mocked one. 260 | func UseMockedTerminal() *TerminalMock { 261 | f := newFinder() 262 | return f.UseMockedTerminal() 263 | } 264 | 265 | // UseMockedTerminalV2 switches the terminal, which is used from 266 | // this package to a mocked one. 267 | func UseMockedTerminalV2() *TerminalMock { 268 | f := newFinder() 269 | return f.UseMockedTerminalV2() 270 | } 271 | 272 | func (f *finder) UseMockedTerminal() *TerminalMock { 273 | m := &TerminalMock{} 274 | f.term = m 275 | return m 276 | } 277 | 278 | func (f *finder) UseMockedTerminalV2() *TerminalMock { 279 | screen := tcell.NewSimulationScreen("UTF-8") 280 | if err := screen.Init(); err != nil { 281 | panic(err) 282 | } 283 | m := &TerminalMock{ 284 | simScreen: screen, 285 | v2: true, 286 | } 287 | f.term = m 288 | return m 289 | } 290 | 291 | // parseAttr parses an attribute of termbox 292 | // as an escape sequence. 293 | // parseAttr doesn't support output modes othar than color256 in termbox-go. 294 | func parseAttr(attr termbox.Attribute, isFg bool) string { 295 | var buf bytes.Buffer 296 | buf.WriteString("\x1b[") 297 | if attr >= termbox.AttrReverse { 298 | buf.WriteString("7;") 299 | attr -= termbox.AttrReverse 300 | } 301 | if attr >= termbox.AttrUnderline { 302 | buf.WriteString("4;") 303 | attr -= termbox.AttrUnderline 304 | } 305 | if attr >= termbox.AttrBold { 306 | buf.WriteString("1;") 307 | attr -= termbox.AttrBold 308 | } 309 | 310 | if attr > termbox.ColorWhite { 311 | panic(fmt.Sprintf("invalid color code: %d", attr)) 312 | } 313 | 314 | if attr == termbox.ColorDefault { 315 | if isFg { 316 | buf.WriteString("39") 317 | } else { 318 | buf.WriteString("49") 319 | } 320 | } else { 321 | color := int(attr) - 1 322 | if isFg { 323 | fmt.Fprintf(&buf, "38;5;%d", color) 324 | } else { 325 | fmt.Fprintf(&buf, "48;5;%d", color) 326 | } 327 | } 328 | buf.WriteString("m") 329 | 330 | return buf.String() 331 | } 332 | 333 | // parseAttrV2 parses color and attribute for testing. 334 | func parseAttrV2(fg, bg tcell.Color, attr tcell.AttrMask) string { 335 | if attr == tcell.AttrInvalid { 336 | panic("invalid attribute") 337 | } 338 | 339 | var params []string 340 | if attr&tcell.AttrBold == tcell.AttrBold { 341 | params = append(params, "1") 342 | attr ^= tcell.AttrBold 343 | } 344 | if attr&tcell.AttrBlink == tcell.AttrBlink { 345 | params = append(params, "5") 346 | attr ^= tcell.AttrBlink 347 | } 348 | if attr&tcell.AttrReverse == tcell.AttrReverse { 349 | params = append(params, "7") 350 | attr ^= tcell.AttrReverse 351 | } 352 | if attr&tcell.AttrUnderline == tcell.AttrUnderline { 353 | params = append(params, "4") 354 | attr ^= tcell.AttrUnderline 355 | } 356 | if attr&tcell.AttrDim == tcell.AttrDim { 357 | params = append(params, "2") 358 | attr ^= tcell.AttrDim 359 | } 360 | if attr&tcell.AttrItalic == tcell.AttrItalic { 361 | params = append(params, "3") 362 | attr ^= tcell.AttrItalic 363 | } 364 | if attr&tcell.AttrStrikeThrough == tcell.AttrStrikeThrough { 365 | params = append(params, "9") 366 | attr ^= tcell.AttrStrikeThrough 367 | } 368 | 369 | switch { 370 | case fg == 0: // Ignore. 371 | case fg == tcell.ColorDefault: 372 | params = append(params, "39") 373 | case fg > tcell.Color255: 374 | r, g, b := fg.RGB() 375 | params = append(params, "38", "2", fmt.Sprint(r), fmt.Sprint(g), fmt.Sprint(b)) 376 | default: 377 | params = append(params, "38", "5", fmt.Sprint(fg-tcell.ColorValid)) 378 | } 379 | 380 | switch { 381 | case bg == 0: // Ignore. 382 | case bg == tcell.ColorDefault: 383 | params = append(params, "49") 384 | case bg > tcell.Color255: 385 | r, g, b := bg.RGB() 386 | params = append(params, "48", "2", fmt.Sprint(r), fmt.Sprint(g), fmt.Sprint(b)) 387 | default: 388 | params = append(params, "48", "5", fmt.Sprint(bg-tcell.ColorValid)) 389 | } 390 | 391 | return fmt.Sprintf("\x1b[%sm", strings.Join(params, ";")) 392 | } 393 | 394 | func toAnsi3bit(color tcell.Color) int { 395 | colors := []tcell.Color{ 396 | tcell.ColorBlack, tcell.ColorRed, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorBlue, tcell.ColorDarkMagenta, tcell.ColorDarkCyan, tcell.ColorWhite, 397 | } 398 | for i, c := range colors { 399 | if c == color { 400 | return i 401 | } 402 | } 403 | return 0 404 | } 405 | -------------------------------------------------------------------------------- /mock_test.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/nsf/termbox-go" 8 | ) 9 | 10 | func Test_parseAttr(t *testing.T) { 11 | cases := map[string]struct { 12 | attr termbox.Attribute 13 | isFg bool 14 | expected string 15 | willPanic bool 16 | }{ 17 | "ColorDefault": { 18 | attr: termbox.ColorDefault, 19 | isFg: true, 20 | expected: "\x1b[39m", 21 | }, 22 | "ColorDefault bg": { 23 | attr: termbox.ColorDefault, 24 | expected: "\x1b[49m", 25 | }, 26 | "ColorGreen": { 27 | attr: termbox.ColorGreen, 28 | expected: "\x1b[48;5;2m", 29 | }, 30 | "ColorGreen with bold": { 31 | attr: termbox.ColorGreen | termbox.AttrBold, 32 | expected: "\x1b[1;48;5;2m", 33 | }, 34 | "ColorGreen with bold and underline": { 35 | attr: termbox.ColorGreen | termbox.AttrBold | termbox.AttrUnderline, 36 | expected: "\x1b[4;1;48;5;2m", 37 | }, 38 | "ColorGreen with reverse": { 39 | attr: termbox.ColorGreen | termbox.AttrReverse, 40 | expected: "\x1b[7;48;5;2m", 41 | }, 42 | "invalid color": { 43 | attr: termbox.ColorWhite + 1, 44 | willPanic: true, 45 | }, 46 | } 47 | 48 | for name, c := range cases { 49 | c := c 50 | t.Run(name, func(t *testing.T) { 51 | if c.willPanic { 52 | defer func() { 53 | if err := recover(); err == nil { 54 | t.Errorf("must panic") 55 | } 56 | }() 57 | } 58 | actual := parseAttr(c.attr, c.isFg) 59 | if diff := cmp.Diff(c.expected, actual); diff != "" { 60 | t.Errorf("diff found: \n%s\nexpected = %x, actual = %x", diff, c.expected, actual) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | // func Test_parseAttrV2(t *testing.T) { 67 | // cases := map[string]struct { 68 | // attr tcell.AttrMask 69 | // fg tcell.Color 70 | // bg tcell.Color 71 | // isBg bool 72 | // expected string 73 | // willPanic bool 74 | // }{ 75 | // "ColorDefault": { 76 | // fg: tcell.ColorDefault, 77 | // expected: "\x1b[39m", 78 | // }, 79 | // "ColorDefault bg": { 80 | // bg: tcell.ColorDefault, 81 | // isBg: true, 82 | // expected: "\x1b[49m", 83 | // }, 84 | // "ColorGreen": { 85 | // fg: tcell.ColorGreen, 86 | // expected: "\x1b[38;5;2m", 87 | // }, 88 | // "ColorGreen with bold": { 89 | // attr: tcell.AttrBold, 90 | // fg: tcell.ColorGreen, 91 | // expected: "\x1b[1;38;5;2m", 92 | // }, 93 | // "ColorGreen with bold and underline": { 94 | // attr: tcell.AttrBold | tcell.AttrUnderline, 95 | // fg: tcell.ColorGreen, 96 | // expected: "\x1b[1;4;38;5;2m", 97 | // }, 98 | // "ColorGreen with reverse": { 99 | // attr: tcell.AttrReverse, 100 | // fg: tcell.ColorGreen, 101 | // expected: "\x1b[7;38;5;2m", 102 | // }, 103 | // "invalid color": { 104 | // attr: tcell.AttrInvalid, 105 | // willPanic: true, 106 | // }, 107 | // } 108 | // 109 | // for name, c := range cases { 110 | // c := c 111 | // t.Run(name, func(t *testing.T) { 112 | // if c.willPanic { 113 | // defer func() { 114 | // if err := recover(); err == nil { 115 | // t.Errorf("must panic") 116 | // } 117 | // }() 118 | // } 119 | // var actual string 120 | // if c.isBg { 121 | // actual = parseAttrV2(nil, &c.bg, c.attr) 122 | // } else { 123 | // actual = parseAttrV2(&c.fg, nil, c.attr) 124 | // } 125 | // if diff := cmp.Diff(c.expected, actual); diff != "" { 126 | // t.Errorf("diff found: \n%s\nexpected = %x, actual = %x", diff, c.expected, actual) 127 | // } 128 | // }) 129 | // } 130 | // } 131 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | type opt struct { 9 | mode mode 10 | previewFunc func(i, width, height int) string 11 | multi bool 12 | hotReload bool 13 | hotReloadLock sync.Locker 14 | promptString string 15 | header string 16 | beginAtTop bool 17 | context context.Context 18 | query string 19 | selectOne bool 20 | preselected func(i int) bool 21 | } 22 | 23 | type mode int 24 | 25 | const ( 26 | // ModeSmart enables a smart matching. It is the default matching mode. 27 | // At the beginning, matching mode is ModeCaseInsensitive, but it switches 28 | // over to ModeCaseSensitive if an upper case character is inputted. 29 | ModeSmart mode = iota 30 | // ModeCaseSensitive enables a case-sensitive matching. 31 | ModeCaseSensitive 32 | // ModeCaseInsensitive enables a case-insensitive matching. 33 | ModeCaseInsensitive 34 | ) 35 | 36 | var defaultOption = opt{ 37 | promptString: "> ", 38 | hotReloadLock: &sync.Mutex{}, // this won't resolve the race condition but avoid nil panic 39 | preselected: func(i int) bool { return false }, 40 | } 41 | 42 | // Option represents available fuzzy-finding options. 43 | type Option func(*opt) 44 | 45 | // WithMode specifies a matching mode. The default mode is ModeSmart. 46 | func WithMode(m mode) Option { 47 | return func(o *opt) { 48 | o.mode = m 49 | } 50 | } 51 | 52 | // WithPreviewWindow enables to display a preview for the selected item. 53 | // The argument f receives i, width and height. i is the same as Find's one. 54 | // width and height are the size of the terminal so that you can use these to adjust 55 | // a preview content. Note that width and height are calculated as a rune-based length. 56 | // 57 | // If there is no selected item, previewFunc passes -1 to previewFunc. 58 | // 59 | // If f is nil, the preview feature is disabled. 60 | func WithPreviewWindow(f func(i, width, height int) string) Option { 61 | return func(o *opt) { 62 | o.previewFunc = f 63 | } 64 | } 65 | 66 | // WithHotReload reloads the passed slice automatically when some entries are appended. 67 | // The caller must pass a pointer of the slice instead of the slice itself. 68 | // 69 | // Deprecated: use WithHotReloadLock instead. 70 | func WithHotReload() Option { 71 | return func(o *opt) { 72 | o.hotReload = true 73 | } 74 | } 75 | 76 | // WithHotReloadLock reloads the passed slice automatically when some entries are appended. 77 | // The caller must pass a pointer of the slice instead of the slice itself. 78 | // The caller must pass a RLock which is used to synchronize access to the slice. 79 | // The caller MUST NOT lock in the itemFunc passed to Find / FindMulti because it will be locked by the fuzzyfinder. 80 | // If used together with WithPreviewWindow, the caller MUST use the RLock only in the previewFunc passed to WithPreviewWindow. 81 | func WithHotReloadLock(lock sync.Locker) Option { 82 | return func(o *opt) { 83 | o.hotReload = true 84 | o.hotReloadLock = lock 85 | } 86 | } 87 | 88 | type cursorPosition int 89 | 90 | const ( 91 | CursorPositionBottom cursorPosition = iota 92 | CursorPositionTop 93 | ) 94 | 95 | // WithCursorPosition sets the initial position of the cursor 96 | // 97 | // If Find is called with WithCursorPosition and WithPreselected, the cursor will be positioned at the first preselected item. 98 | func WithCursorPosition(position cursorPosition) Option { 99 | return func(o *opt) { 100 | switch position { 101 | case CursorPositionTop: 102 | o.beginAtTop = true 103 | case CursorPositionBottom: 104 | o.beginAtTop = false 105 | } 106 | } 107 | } 108 | 109 | // WithPromptString changes the prompt string. The default value is "> ". 110 | func WithPromptString(s string) Option { 111 | return func(o *opt) { 112 | o.promptString = s 113 | } 114 | } 115 | 116 | // withMulti enables to select multiple items by tab key. 117 | func withMulti() Option { 118 | return func(o *opt) { 119 | o.multi = true 120 | } 121 | } 122 | 123 | // WithHeader enables to set the header. 124 | func WithHeader(s string) Option { 125 | return func(o *opt) { 126 | o.header = s 127 | } 128 | } 129 | 130 | // WithContext enables closing the fuzzy finder from parent. 131 | func WithContext(ctx context.Context) Option { 132 | return func(o *opt) { 133 | o.context = ctx 134 | } 135 | } 136 | 137 | // WithQuery enables to set the initial query. 138 | func WithQuery(s string) Option { 139 | return func(o *opt) { 140 | o.query = s 141 | } 142 | } 143 | 144 | // WithQuery enables to set the initial query. 145 | func WithSelectOne() Option { 146 | return func(o *opt) { 147 | o.selectOne = true 148 | } 149 | } 150 | 151 | // WithPreselected enables to specify which items should be preselected. 152 | // The argument f is a function that returns true for items that should be preselected. 153 | // i is the same index value passed to itemFunc in Find or FindMulti. 154 | // This option is effective in both Find and FindMulti, but in Find mode only 155 | // the first preselected item will be considered. 156 | // 157 | // If Find is called with WithCursorPosition and WithPreselected, the cursor will be positioned at the first preselected item. 158 | func WithPreselected(f func(i int) bool) Option { 159 | return func(o *opt) { 160 | o.preselected = f 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /scoring/scoring.go: -------------------------------------------------------------------------------- 1 | // Package scoring provides APIs that calculates similarity scores between two strings. 2 | package scoring 3 | 4 | // Calculate calculates a similarity score between s1 and s2. 5 | // The length of s1 must be greater or equal than the length of s2. 6 | func Calculate(s1, s2 string) (int, [2]int) { 7 | if len(s1) < len(s2) { 8 | panic("len(s1) must be greater than or equal to len(s2)") 9 | } 10 | 11 | return smithWaterman([]rune(s1), []rune(s2)) 12 | } 13 | 14 | // max returns the biggest number from passed args. 15 | // If the number of args is 0, it always returns 0. 16 | func max(n ...int32) (min int32) { 17 | if len(n) == 0 { 18 | return 0 19 | } 20 | min = n[0] 21 | for _, a := range n[1:] { 22 | if a > min { 23 | min = a 24 | } 25 | } 26 | return 27 | } 28 | -------------------------------------------------------------------------------- /scoring/scoring_test.go: -------------------------------------------------------------------------------- 1 | package scoring 2 | 3 | import "testing" 4 | 5 | func TestCalculate(t *testing.T) { 6 | t.Parallel() 7 | 8 | cases := map[string]struct { 9 | s1, s2 string 10 | willPanic bool 11 | }{ 12 | "must not panic": {s1: "foo", s2: "foo"}, 13 | "must not panic2": {s1: "", s2: ""}, 14 | "must panic": {s1: "foo", s2: "foobar", willPanic: true}, 15 | } 16 | 17 | for _, c := range cases { 18 | if c.willPanic { 19 | defer func() { 20 | if err := recover(); err == nil { 21 | t.Error("Calculate must panic") 22 | } 23 | }() 24 | } 25 | Calculate(c.s1, c.s2) 26 | } 27 | } 28 | 29 | func Test_max(t *testing.T) { 30 | t.Parallel() 31 | 32 | if n := max(); n != 0 { 33 | t.Errorf("max must return 0 if no args, but got %d", n) 34 | } 35 | 36 | if n := max(0, -1, 10, 3); n != 10 { 37 | t.Errorf("max must return the maximun number 10, but got %d", n) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /scoring/smith_waterman.go: -------------------------------------------------------------------------------- 1 | package scoring 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "unicode" 7 | ) 8 | 9 | // smithWaterman calculates a simularity score between s1 and s2 10 | // by smith-waterman algorithm. smith-waterman algorithm is one of 11 | // local alignment algorithms and it uses dynamic programming. 12 | // 13 | // In this smith-waterman algorithm, we use the affine gap penalty. 14 | // Please see https://en.wikipedia.org/wiki/Gap_penalty#Affine for additional 15 | // information about the affine gap penalty. 16 | // 17 | // We calculate the gap penalty by the Gotoh's algorithm, which optimizes 18 | // the calculation from O(M^2N) to O(MN). 19 | // Please see ftp://150.128.97.71/pub/Bioinformatica/gotoh1982.pdf for more details. 20 | func smithWaterman(s1, s2 []rune) (int, [2]int) { 21 | if len(s1) == 0 { 22 | // If the length of s1 is 0, also the length of s2 is 0. 23 | return 0, [2]int{-1, -1} 24 | } 25 | 26 | const ( 27 | openGap int32 = 5 // Gap opening penalty. 28 | extGap int32 = 1 // Gap extension penalty. 29 | 30 | matchScore int32 = 5 31 | mismatchScore int32 = 1 32 | 33 | firstCharBonus int32 = 3 // The first char of s1 is equal to s2's one. 34 | ) 35 | 36 | // The scoring matrix. 37 | H := make([][]int32, len(s1)+1) 38 | // A matrix that calculates gap penalties for s2 until each position (i, j). 39 | // Note that, we don't need a matrix for s1 because s1 contains all runes 40 | // of s2 so that s1 is not inserted gaps. 41 | D := make([][]int32, len(s1)+1) 42 | for i := 0; i <= len(s1); i++ { 43 | H[i] = make([]int32, len(s2)+1) 44 | D[i] = make([]int32, len(s2)+1) 45 | } 46 | 47 | for i := 0; i <= len(s1); i++ { 48 | D[i][0] = -openGap - int32(i)*extGap 49 | } 50 | 51 | // Calculate bonuses for each rune of s1. 52 | bonus := make([]int32, len(s1)) 53 | bonus[0] = firstCharBonus 54 | prevCh := s1[0] 55 | prevIsDelimiter := isDelimiter(prevCh) 56 | for i, r := range s1[1:] { 57 | isDelimiter := isDelimiter(r) 58 | if prevIsDelimiter && !isDelimiter { 59 | bonus[i] = firstCharBonus 60 | } 61 | prevIsDelimiter = isDelimiter 62 | } 63 | 64 | var maxScore int32 65 | var maxI int 66 | var maxJ int 67 | for i := 1; i <= len(s1); i++ { 68 | for j := 1; j <= len(s2); j++ { 69 | var score int32 70 | if s1[i-1] != s2[j-1] { 71 | score = H[i-1][j-1] - mismatchScore 72 | } else { 73 | score = H[i-1][j-1] + matchScore + bonus[i-1] 74 | } 75 | H[i][j] += max(D[i-1][j], score, 0) 76 | 77 | D[i][j] = max(H[i-1][j]-openGap, D[i-1][j]-extGap) 78 | 79 | // Update the max score. 80 | // Don't pick a position that is less than the length of s2. 81 | if H[i][j] > maxScore && i >= j { 82 | maxScore = H[i][j] 83 | maxI = i - 1 84 | maxJ = j - 1 85 | } 86 | } 87 | } 88 | 89 | if isDebug() { 90 | fmt.Printf("max score = %d (%d, %d)\n\n", maxScore, maxI, maxJ) 91 | printSlice := func(m [][]int32) { 92 | fmt.Printf("%4c ", '|') 93 | for i := 0; i < len(s2); i++ { 94 | fmt.Printf("%3c ", s2[i]) 95 | } 96 | fmt.Printf("\n-------------------------\n") 97 | 98 | fmt.Print(" | ") 99 | for i := 0; i <= len(s1); i++ { 100 | if i != 0 { 101 | fmt.Printf("%3c| ", s1[i-1]) 102 | } 103 | for j := 0; j <= len(s2); j++ { 104 | fmt.Printf("%3d ", m[i][j]) 105 | } 106 | fmt.Println() 107 | } 108 | fmt.Println() 109 | } 110 | printSlice(H) 111 | printSlice(D) 112 | } 113 | 114 | // Determine the matched position. 115 | 116 | var from, to int 117 | cnt := 1 118 | 119 | // maxJ is the last index of s2. 120 | // If maxJ is equal to the length of s2, it means there are no matched runes after maxJ. 121 | if maxJ == len(s2)-1 { 122 | to = maxI 123 | } else { 124 | j := maxJ + 1 125 | for i := maxI + 1; i < len(s1); i++ { 126 | if unicode.ToLower(s1[i]) == unicode.ToLower(s2[j]) { 127 | cnt++ 128 | j++ 129 | if j == len(s2) { 130 | to = i + 1 131 | break 132 | } 133 | } 134 | } 135 | } 136 | 137 | for i := maxI - 1; i > 0; i-- { 138 | if cnt == len(s2) { 139 | from = i + 1 140 | break 141 | } 142 | if unicode.ToLower(s1[i]) == unicode.ToLower(s2[len(s2)-1-cnt]) { 143 | cnt++ 144 | } 145 | } 146 | 147 | // We adjust scores by the weight per one rune. 148 | return int(float32(maxScore) * (float32(maxScore) / float32(len(s1)))), [2]int{from, to} 149 | } 150 | 151 | func isDebug() bool { 152 | return os.Getenv("DEBUG") != "" 153 | } 154 | 155 | var delimiterRunes = map[rune]interface{}{ 156 | '(': nil, 157 | '[': nil, 158 | '{': nil, 159 | '/': nil, 160 | '-': nil, 161 | '_': nil, 162 | '.': nil, 163 | } 164 | 165 | func isDelimiter(r rune) bool { 166 | if _, ok := delimiterRunes[r]; ok { 167 | return true 168 | } 169 | return unicode.IsSpace(r) 170 | } 171 | -------------------------------------------------------------------------------- /scoring/smith_waterman_test.go: -------------------------------------------------------------------------------- 1 | package scoring 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func Test_smithWaterman(t *testing.T) { 10 | t.Parallel() 11 | 12 | old := os.Getenv("DEBUG") 13 | os.Setenv("DEBUG", "true") 14 | defer os.Setenv("DEBUG", old) 15 | 16 | cases := []struct { 17 | s1, s2 string 18 | expectedScore int 19 | expectedPos [2]int 20 | }{ 21 | {"TACGGGCCCGCTA", "TAGCCCTA", 78, [2]int{0, 12}}, 22 | {"TACGGG-CCCGCTA", "TAGCCCTA", 56, [2]int{0, 13}}, 23 | {"FLY ME TO THE MOON", "MEON", 10, [2]int{4, 17}}, 24 | } 25 | 26 | for _, c := range cases { 27 | c := c 28 | name := fmt.Sprintf("%s-%s", c.s1, c.s2) 29 | t.Run(name, func(t *testing.T) { 30 | t.Parallel() 31 | 32 | score, pos := smithWaterman([]rune(c.s1), []rune(c.s2)) 33 | if score != c.expectedScore { 34 | t.Errorf("expected 78, but got %d", score) 35 | } 36 | if pos != c.expectedPos { 37 | t.Errorf("expected %v, but got %v", c.expectedPos, pos) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func Benchmark_smithWaterman(b *testing.B) { 44 | for i := 0; i < b.N; i++ { 45 | smithWaterman([]rune("TACGGGCCCGCTA"), []rune("TAGCCCTA")) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tcell.go: -------------------------------------------------------------------------------- 1 | package fuzzyfinder 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | ) 6 | 7 | type screen tcell.Screen 8 | 9 | type terminal interface { 10 | screen 11 | } 12 | 13 | type termImpl struct { 14 | screen 15 | } 16 | -------------------------------------------------------------------------------- /testdata/fixtures/testfind-arrow_left-right.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > ゆるふわ樹海 └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-arrow_left_backspace.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > オレジ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-arrow_up-down.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: ヒトリノ夜 │ 3 | glow │ Artist: ポルノグラフィテ.. │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | > ヒトリノ夜 │ │ 8 | あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-backspace.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > adr █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-backspace2.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > オレ█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-backspace_doesnt_change_x_if_cursorx_is_0.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ Name: adrenaline!!! │ 3 | │ Artist: TrySail │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | Catch the Moment │ │ 8 | > adrenaline!!! │ │ 9 | 2/9 │ │ 10 | > a█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-backspace_empty.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: あの日自分が出て行.. │ 3 | glow │ Artist: │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-ctrl-e.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > 恋をしたのは█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-ctrl-u.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > は█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-ctrl-w.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > ハロ / █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-ctrl-w_empty.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: あの日自分が出て行.. │ 3 | glow │ Artist: │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-cursor_begins_at_top.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: Catch the Moment │ 3 | glow │ Artist: LiSA │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-delete.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ not found │ 3 | │ │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | │ │ 9 | 0/9 │ │ 10 | > レンジ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-delete_empty.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: あの日自分が出て行.. │ 3 | glow │ Artist: │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-header_line.golden: -------------------------------------------------------------------------------- 1 | メーベル ┌────────────────────────────┐ 2 | glow │ Name: あの日自分が出て行.. │ 3 | closing │ Artist: │ 4 | ソラニン │ │ 5 | adrenaline!!! │ │ 6 | ヒトリノ夜 │ │ 7 | > あの日自分が出て行ってや.. │ │ 8 | 9/9 │ │ 9 | Search? │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-header_line_which_exceeds_max_charaters.golden: -------------------------------------------------------------------------------- 1 | メーベル ┌────────────────────────────┐ 2 | glow │ Name: あの日自分が出て行.. │ 3 | closing │ Artist: │ 4 | ソラニン │ │ 5 | adrenaline!!! │ │ 6 | ヒトリノ夜 │ │ 7 | > あの日自分が出て行ってや.. │ │ 8 | 9/9 │ │ 9 | Waht do you want to searc.. │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-initial.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: あの日自分が出て行.. │ 3 | glow │ Artist: │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-input_glow.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ Name: glow │ 3 | │ Artist: keeno │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | > glow │ │ 9 | 1/9 │ │ 10 | > glow█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-input_lo.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ Name: glow │ 3 | │ Artist: keeno │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | closing │ │ 8 | > glow │ │ 9 | 2/9 │ │ 10 | > lo█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-long_item.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: ソラニン │ 3 | glow │ Artist: ASIAN KUNG-FU GEN..│ 4 | closing │ │ 5 | > ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-paging.golden: -------------------------------------------------------------------------------- 1 | > Catch the Moment ┌────────────────────────────┐ 2 | ICHIDAIJI │ Name: Catch the Moment │ 3 | メーベル │ Artist: LiSA │ 4 | glow │ │ 5 | closing │ │ 6 | ソラニン │ │ 7 | adrenaline!!! │ │ 8 | ヒトリノ夜 │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-pg-dn.golden: -------------------------------------------------------------------------------- 1 | Catch the Moment ┌────────────────────────────┐ 2 | ICHIDAIJI │ Name: ヒトリノ夜 │ 3 | メーベル │ Artist: ポルノグラフィテ.. │ 4 | glow │ │ 5 | closing │ │ 6 | ソラニン │ │ 7 | adrenaline!!! │ │ 8 | > ヒトリノ夜 │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-pg-dn_twice.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: あの日自分が出て行.. │ 3 | glow │ Artist: │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-pg-up.golden: -------------------------------------------------------------------------------- 1 | > ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: ICHIDAIJI │ 3 | glow │ Artist: ポルカドットステ.. │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-pg-up_twice.golden: -------------------------------------------------------------------------------- 1 | > Catch the Moment ┌────────────────────────────┐ 2 | ICHIDAIJI │ Name: Catch the Moment │ 3 | メーベル │ Artist: LiSA │ 4 | glow │ │ 5 | closing │ │ 6 | ソラニン │ │ 7 | adrenaline!!! │ │ 8 | ヒトリノ夜 │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind-tab_doesnt_work.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ Name: あの日自分が出て行.. │ 3 | glow │ Artist: │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_hotreload.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ Name: adrenaline!!! │ 3 | │ Artist: TrySail │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | > adrenaline!!! │ │ 9 | 1/9 │ │ 10 | > adrena█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_hotreloadlock.golden: -------------------------------------------------------------------------------- 1 | ┌────────────────────────────┐ 2 | │ Name: adrenaline!!! │ 3 | │ Artist: TrySail │ 4 | │ │ 5 | │ │ 6 | │ │ 7 | │ │ 8 | > adrenaline!!! │ │ 9 | 1/9 │ │ 10 | > adrena█ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withcontext.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withpreviewwindow-multiline.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ foo │ 3 | glow │ bar │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withpreviewwindow-normal.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ foo │ 3 | glow │ │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withpreviewwindow-overflowed_line.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ foofoofoofoofoofoofoofoof..│ 3 | glow │ │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withpreviewwindow-sgr.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ abcdefgh │ 3 | glow │ │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withpreviewwindow-sgr_with_overflowed_line.golden: -------------------------------------------------------------------------------- 1 | ICHIDAIJI ┌────────────────────────────┐ 2 | メーベル │ abcdefghfoofoofoofoofoofo..│ 3 | glow │ │ 4 | closing │ │ 5 | ソラニン │ │ 6 | adrenaline!!! │ │ 7 | ヒトリノ夜 │ │ 8 | > あの日自分が出て行ってや.. │ │ 9 | 9/9 │ │ 10 | > █ └────────────────────────────┘ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withquery-has_initial_query.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | > three2one 9 | 1/2 10 | > three2one█ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withquery-no_initial_query.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | three2one 8 | > one 9 | 2/2 10 | > one█ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withselectone-has_initial_query.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withselectone-more_than_one.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | two 8 | > one 9 | 2/2 10 | > █ 11 |  -------------------------------------------------------------------------------- /testdata/fixtures/testfind_withselectone-only_one_option.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |  -------------------------------------------------------------------------------- /tools/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Code-Hex/go-fuzzyfinder/tools 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Songmu/gocredits v0.2.0 9 | github.com/goreleaser/goreleaser v0.184.0 10 | ) 11 | 12 | require ( 13 | cloud.google.com/go v0.110.0 // indirect 14 | cloud.google.com/go/compute v1.19.1 // indirect 15 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 16 | cloud.google.com/go/iam v0.13.0 // indirect 17 | cloud.google.com/go/kms v1.10.1 // indirect 18 | cloud.google.com/go/storage v1.28.1 // indirect 19 | code.gitea.io/sdk/gitea v0.15.0 // indirect 20 | dario.cat/mergo v1.0.0 // indirect 21 | github.com/AlekSi/pointer v1.2.0 // indirect 22 | github.com/Azure/azure-pipeline-go v0.2.3 // indirect 23 | github.com/Azure/azure-sdk-for-go v57.0.0+incompatible // indirect 24 | github.com/Azure/azure-storage-blob-go v0.14.0 // indirect 25 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 26 | github.com/Azure/go-autorest/autorest v0.11.20 // indirect 27 | github.com/Azure/go-autorest/autorest/adal v0.9.15 // indirect 28 | github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 // indirect 29 | github.com/Azure/go-autorest/autorest/azure/cli v0.4.3 // indirect 30 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 31 | github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect 32 | github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect 33 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 34 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 35 | github.com/DisgoOrg/disgohook v1.4.3 // indirect 36 | github.com/DisgoOrg/log v1.1.0 // indirect 37 | github.com/DisgoOrg/restclient v1.2.7 // indirect 38 | github.com/Masterminds/goutils v1.1.1 // indirect 39 | github.com/Masterminds/semver v1.5.0 // indirect 40 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 41 | github.com/Masterminds/sprig v2.22.0+incompatible // indirect 42 | github.com/Microsoft/go-winio v0.6.1 // indirect 43 | github.com/ProtonMail/go-crypto v1.1.3 // indirect 44 | github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect 45 | github.com/apex/log v1.9.0 // indirect 46 | github.com/atc0005/go-teams-notify/v2 v2.6.0 // indirect 47 | github.com/aws/aws-sdk-go v1.40.34 // indirect 48 | github.com/aws/aws-sdk-go-v2 v1.9.0 // indirect 49 | github.com/aws/aws-sdk-go-v2/config v1.7.0 // indirect 50 | github.com/aws/aws-sdk-go-v2/credentials v1.4.0 // indirect 51 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0 // indirect 52 | github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2 // indirect 53 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0 // indirect 54 | github.com/aws/aws-sdk-go-v2/service/kms v1.5.0 // indirect 55 | github.com/aws/aws-sdk-go-v2/service/sso v1.4.0 // indirect 56 | github.com/aws/aws-sdk-go-v2/service/sts v1.7.0 // indirect 57 | github.com/aws/smithy-go v1.8.0 // indirect 58 | github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect 59 | github.com/caarlos0/ctrlc v1.0.0 // indirect 60 | github.com/caarlos0/env/v6 v6.7.0 // indirect 61 | github.com/caarlos0/go-shellwords v1.0.12 // indirect 62 | github.com/cavaliergopher/cpio v1.0.1 // indirect 63 | github.com/cenkalti/backoff v2.1.1+incompatible // indirect 64 | github.com/cloudflare/circl v1.3.7 // indirect 65 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 66 | github.com/cyphar/filepath-securejoin v0.2.5 // indirect 67 | github.com/dghubble/go-twitter v0.0.0-20210609183100-2fdbf421508e // indirect 68 | github.com/dghubble/oauth1 v0.7.0 // indirect 69 | github.com/dghubble/sling v1.3.0 // indirect 70 | github.com/dimchansky/utfbom v1.1.1 // indirect 71 | github.com/emirpasic/gods v1.18.1 // indirect 72 | github.com/fatih/color v1.16.0 // indirect 73 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 74 | github.com/go-git/go-billy/v5 v5.6.0 // indirect 75 | github.com/go-git/go-git/v5 v5.13.0 // indirect 76 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect 77 | github.com/gobwas/glob v0.2.3 // indirect 78 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 79 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 80 | github.com/golang/protobuf v1.5.3 // indirect 81 | github.com/google/go-cmp v0.6.0 // indirect 82 | github.com/google/go-github/v39 v39.2.0 // indirect 83 | github.com/google/go-querystring v1.1.0 // indirect 84 | github.com/google/uuid v1.3.0 // indirect 85 | github.com/google/wire v0.5.0 // indirect 86 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect 87 | github.com/googleapis/gax-go/v2 v2.7.1 // indirect 88 | github.com/goreleaser/chglog v0.4.2 // indirect 89 | github.com/goreleaser/fileglob v1.3.0 // indirect 90 | github.com/goreleaser/nfpm/v2 v2.29.0 // indirect 91 | github.com/gorilla/websocket v1.4.2 // indirect 92 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 93 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 94 | github.com/hashicorp/go-version v1.2.1 // indirect 95 | github.com/huandu/xstrings v1.3.2 // indirect 96 | github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect 97 | github.com/imdario/mergo v0.3.15 // indirect 98 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 99 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 100 | github.com/jmespath/go-jmespath v0.4.0 // indirect 101 | github.com/kevinburke/ssh_config v1.2.0 // indirect 102 | github.com/klauspost/compress v1.16.5 // indirect 103 | github.com/klauspost/pgzip v1.2.6 // indirect 104 | github.com/mattn/go-colorable v0.1.13 // indirect 105 | github.com/mattn/go-ieproxy v0.0.1 // indirect 106 | github.com/mattn/go-isatty v0.0.20 // indirect 107 | github.com/mitchellh/copystructure v1.2.0 // indirect 108 | github.com/mitchellh/go-homedir v1.1.0 // indirect 109 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 110 | github.com/pjbgf/sha1cd v0.3.0 // indirect 111 | github.com/pkg/errors v0.9.1 // indirect 112 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 113 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 114 | github.com/skeema/knownhosts v1.3.0 // indirect 115 | github.com/slack-go/slack v0.9.4 // indirect 116 | github.com/spf13/cobra v1.7.0 // indirect 117 | github.com/spf13/pflag v1.0.5 // indirect 118 | github.com/technoweenie/multipartstreamer v1.0.1 // indirect 119 | github.com/ulikunitz/xz v0.5.11 // indirect 120 | github.com/vartanbeno/go-reddit/v2 v2.0.0 // indirect 121 | github.com/xanzy/go-gitlab v0.50.3 // indirect 122 | github.com/xanzy/ssh-agent v0.3.3 // indirect 123 | gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect 124 | go.opencensus.io v0.24.0 // indirect 125 | gocloud.dev v0.24.0 // indirect 126 | golang.org/x/crypto v0.36.0 // indirect 127 | golang.org/x/mod v0.17.0 // indirect 128 | golang.org/x/net v0.38.0 // indirect 129 | golang.org/x/oauth2 v0.7.0 // indirect 130 | golang.org/x/sync v0.12.0 // indirect 131 | golang.org/x/sys v0.31.0 // indirect 132 | golang.org/x/text v0.23.0 // indirect 133 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 134 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 135 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 136 | google.golang.org/api v0.114.0 // indirect 137 | google.golang.org/appengine v1.6.7 // indirect 138 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 139 | google.golang.org/grpc v1.56.3 // indirect 140 | google.golang.org/protobuf v1.33.0 // indirect 141 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 142 | gopkg.in/mail.v2 v2.3.1 // indirect 143 | gopkg.in/warnings.v0 v0.1.2 // indirect 144 | gopkg.in/yaml.v2 v2.4.0 // indirect 145 | gopkg.in/yaml.v3 v3.0.1 // indirect 146 | ) 147 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/Songmu/gocredits/cmd/gocredits" 8 | _ "github.com/goreleaser/goreleaser" 9 | ) 10 | --------------------------------------------------------------------------------