mirror of
https://github.com/junegunn/fzf
synced 2026-08-09 19:51:46 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14755d683a | |||
| 9e2006306a | |||
| c60bcf67a5 |
@@ -12,6 +12,6 @@ jobs:
|
|||||||
label:
|
label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v7
|
- uses: actions/labeler@v6
|
||||||
with:
|
with:
|
||||||
configuration-path: .github/labeler.yml
|
configuration-path: .github/labeler.yml
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version: "1.23"
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.4.6
|
ruby-version: 3.4.6
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version: "1.23"
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.0.0
|
ruby-version: 3.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-go@v7
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,6 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
0.74.3
|
|
||||||
------
|
|
||||||
- Performance optimizations for non-ASCII input
|
|
||||||
- A line holding any non-ASCII character is kept as a rune array, and the prefilter did not run on those lines, so every item went through the full score matrix
|
|
||||||
- Queries are up to 16x faster, the gain growing with how much of the line is non-ASCII
|
|
||||||
- Non-ASCII queries are up to 12x faster
|
|
||||||
- Reading non-ASCII input is up to 37% faster and uses up to 29% less memory, the gain depending on how early the first non-ASCII character appears in the line
|
|
||||||
- Accented Latin and fullwidth forms get the faster reading but not the faster queries, because those characters can still match their ASCII counterparts
|
|
||||||
- ASCII input is unaffected
|
|
||||||
- Fixed an image from a preview command being torn apart when its rows are separated by IND instead of newlines, as `chafa` does under tmux (#4885)
|
|
||||||
- Fixed `replace-query` corrupting the item text when the query is edited afterwards
|
|
||||||
|
|
||||||
0.74.2
|
|
||||||
------
|
|
||||||
- Performance optimizations for short queries
|
|
||||||
- Short queries scan the largest candidate sets, and the first keystroke scans the whole input
|
|
||||||
- Single-character queries are up to 2.4x faster
|
|
||||||
- Two-character queries are up to 1.4x faster
|
|
||||||
- Faster sorting of search results, skipping redundant radix passes
|
|
||||||
- `change-border-label` and `transform-border-label` now work on the native border of a tmux or Zellij floating pane
|
|
||||||
- Fixed Kitty graphics sequences from a preview command being taken by tmux as pane title requests
|
|
||||||
- Fixed an image at the top of the preview being torn by `--preview-window ~N`
|
|
||||||
- Fixed nondeterministic match highlight positions
|
|
||||||
- Fixed signal and resize handlers persisting after `Run()` returns when fzf is used as a library
|
|
||||||
- fzf now detects terminal resize on Windows in `--height` mode (#4790) (@Cyrus580529)
|
|
||||||
- fish: fixed history command being affected by user initialization scripts, and improved timestamp colors in CTRL-R (#4862) (@bitraid)
|
|
||||||
- zsh: fixed CTRL-R not propagating the exit status of fzf when perl is available (#4871) (@LangLangBart, @Toliak)
|
|
||||||
- zsh: fixed `chpwd` hook functions being called twice by ALT-C (#4879) (@LangLangBart, @lucc)
|
|
||||||
|
|
||||||
0.74.1
|
0.74.1
|
||||||
------
|
------
|
||||||
- The default separator on the info line is no longer shown when the input section is already visually separated from the list section by a border line
|
- The default separator on the info line is no longer shown when the input section is already visually separated from the list section by a border line
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ GEM
|
|||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
json (2.19.9)
|
json (2.9.1)
|
||||||
language_server-protocol (3.17.0.3)
|
language_server-protocol (3.17.0.3)
|
||||||
minitest (5.25.4)
|
minitest (5.25.4)
|
||||||
parallel (1.26.3)
|
parallel (1.26.3)
|
||||||
|
|||||||
@@ -97,16 +97,6 @@ test: $(SOURCES)
|
|||||||
itest:
|
itest:
|
||||||
ruby test/runner.rb
|
ruby test/runner.rb
|
||||||
|
|
||||||
# Actively fuzz the matcher fast paths against the general algorithm.
|
|
||||||
# Go fuzzes one target at a time, so iterate. Override duration with
|
|
||||||
# FUZZTIME (e.g. make fuzz FUZZTIME=5m).
|
|
||||||
FUZZTIME ?= 30s
|
|
||||||
fuzz:
|
|
||||||
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \
|
|
||||||
echo "== $$t =="; \
|
|
||||||
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
|
|
||||||
done
|
|
||||||
|
|
||||||
bench:
|
bench:
|
||||||
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
|
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ triggered by a tag push.
|
|||||||
2. Verify file consistency, sign the tag, and push the tag.
|
2. Verify file consistency, sign the tag, and push the tag.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make tag VERSION=0.74.2
|
make tag VERSION=0.74.0
|
||||||
```
|
```
|
||||||
|
|
||||||
`make tag` runs `prerelease` first (checks that the version
|
`make tag` runs `prerelease` first (checks that the version
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ require (
|
|||||||
github.com/charlievieth/fastwalk v1.0.14
|
github.com/charlievieth/fastwalk v1.0.14
|
||||||
github.com/gdamore/tcell/v2 v2.9.0
|
github.com/gdamore/tcell/v2 v2.9.0
|
||||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
|
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
|
||||||
github.com/mattn/go-isatty v0.0.24
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/rivo/uniseg v0.4.7
|
github.com/rivo/uniseg v0.4.7
|
||||||
golang.org/x/sys v0.35.0
|
golang.org/x/sys v0.35.0
|
||||||
golang.org/x/term v0.34.0
|
golang.org/x/term v0.34.0
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
|
|||||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
version=0.74.2
|
version=0.74.0
|
||||||
auto_completion=
|
auto_completion=
|
||||||
key_bindings=
|
key_bindings=
|
||||||
update_config=2
|
update_config=2
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
$version="0.74.2"
|
$version="0.74.0"
|
||||||
|
|
||||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
..
|
..
|
||||||
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.2" "fzf\-tmux - open fzf in tmux split pane"
|
.TH fzf\-tmux 1 "Jul 2026" "fzf 0.74.0" "fzf\-tmux - open fzf in tmux split pane"
|
||||||
|
|
||||||
.SH NAME
|
.SH NAME
|
||||||
fzf\-tmux - open fzf in tmux split pane
|
fzf\-tmux - open fzf in tmux split pane
|
||||||
|
|||||||
+4
-8
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
..
|
..
|
||||||
.TH fzf 1 "Aug 2026" "fzf 0.74.2" "fzf - a command-line fuzzy finder"
|
.TH fzf 1 "Jul 2026" "fzf 0.74.0" "fzf - a command-line fuzzy finder"
|
||||||
|
|
||||||
.SH NAME
|
.SH NAME
|
||||||
fzf - a command-line fuzzy finder
|
fzf - a command-line fuzzy finder
|
||||||
@@ -426,13 +426,9 @@ On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can
|
|||||||
switch to other panes and windows while fzf is running, and move and resize
|
switch to other panes and windows while fzf is running, and move and resize
|
||||||
the pane with the mouse. The native border of the pane is the handle for
|
the pane with the mouse. The native border of the pane is the handle for
|
||||||
moving and resizing it, so it is used by default and \fBborder\-native\fR is
|
moving and resizing it, so it is used by default and \fBborder\-native\fR is
|
||||||
implied. \fB\-\-border\-label\fR is displayed on the native border, and
|
implied. On tmux, \fB\-\-border\-label\fR is set as the title of the pane,
|
||||||
\fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it
|
and is displayed on the border if \fBpane\-border\-status\fR is enabled in
|
||||||
(\fB\-\-border\-label\-pos\fR is ignored). On tmux, fzf holds the label in
|
tmux (\fB\-\-border\-label\-pos\fR is ignored).
|
||||||
the \fB@fzf\-border\-label\fR option of the pane and sets its
|
|
||||||
\fBpane\-border\-format\fR to read it back, so the label is displayed if
|
|
||||||
\fBpane\-border\-status\fR is enabled in tmux. The title of the pane is left
|
|
||||||
alone. On Zellij, the label is the name of the pane.
|
|
||||||
|
|
||||||
fzf draws its own border instead when a border style is explicitly specified
|
fzf draws its own border instead when a border style is explicitly specified
|
||||||
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
|
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
|
||||||
|
|||||||
+12
-9
@@ -127,21 +127,14 @@ function fzf_key_bindings
|
|||||||
set -l -- total_lines (count $command_line)
|
set -l -- total_lines (count $command_line)
|
||||||
set -l -- fzf_query (string escape -- $command_line[$current_line])
|
set -l -- fzf_query (string escape -- $command_line[$current_line])
|
||||||
|
|
||||||
set -lx -- FZF_DEFAULT_COMMAND "builtin history -z --show-time=(set_color $fish_color_comment 2>/dev/null; or set_color normal)\"%F %a %T%t%s%t\"(set_color normal)"
|
|
||||||
|
|
||||||
# Enable syntax highlighting colors on fish v4.3.3 and newer
|
|
||||||
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
|
|
||||||
set -a -- FZF_DEFAULT_COMMAND "--color=always"
|
|
||||||
end
|
|
||||||
|
|
||||||
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
|
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
|
||||||
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
|
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
|
||||||
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
|
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
|
||||||
'--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \
|
'--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \
|
||||||
'--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \
|
'--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \
|
||||||
"--bind='shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload($FZF_DEFAULT_COMMAND)'" \
|
'--bind="shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload(eval $FZF_DEFAULT_COMMAND)"' \
|
||||||
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
|
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
|
||||||
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --ansi --read0 --print0 --with-shell='(status fish-path)\\ -c)
|
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c)
|
||||||
|
|
||||||
# Add dynamic preview options if preview command isn't already set by user
|
# Add dynamic preview options if preview command isn't already set by user
|
||||||
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
|
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
|
||||||
@@ -154,6 +147,16 @@ function fzf_key_bindings
|
|||||||
|
|
||||||
set -lx FZF_DEFAULT_OPTS_FILE
|
set -lx FZF_DEFAULT_OPTS_FILE
|
||||||
|
|
||||||
|
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z'
|
||||||
|
|
||||||
|
# Enable syntax highlighting colors on fish v4.3.3 and newer
|
||||||
|
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
|
||||||
|
set -a -- FZF_DEFAULT_OPTS '--ansi'
|
||||||
|
set -a -- FZF_DEFAULT_COMMAND '--color=always --show-time=(set_color $fish_color_comment 2>/dev/null; or set_color normal)"%F %a %T%t%s%t"(set_color normal)'
|
||||||
|
else
|
||||||
|
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
|
||||||
|
end
|
||||||
|
|
||||||
# Merge history from other sessions before searching
|
# Merge history from other sessions before searching
|
||||||
test -z "$fish_private_mode"; and builtin history merge
|
test -z "$fish_private_mode"; and builtin history merge
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ fzf-cd-widget() {
|
|||||||
# working directory.
|
# working directory.
|
||||||
# If failed, fallback to the unexpanded path to surface the error to the user.
|
# If failed, fallback to the unexpanded path to surface the error to the user.
|
||||||
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
|
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
|
||||||
dir=$(builtin cd -q >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
|
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
|
||||||
zle push-line # Clear buffer. Auto-restored on next prompt.
|
zle push-line # Clear buffer. Auto-restored on next prompt.
|
||||||
BUFFER="builtin cd -- ${(q)dir}"
|
BUFFER="builtin cd -- ${(q)dir}"
|
||||||
zle accept-line
|
zle accept-line
|
||||||
@@ -139,11 +139,11 @@ fzf-history-widget() {
|
|||||||
# as the associative 'history' array, which maps event numbers to full history
|
# as the associative 'history' array, which maps event numbers to full history
|
||||||
# lines, are set. Also, make sure Perl is installed for multi-line output.
|
# lines, are set. Also, make sure Perl is installed for multi-line output.
|
||||||
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
|
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
|
||||||
extracted_with_perl=1
|
|
||||||
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
|
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
|
||||||
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
|
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
|
||||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
|
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
|
||||||
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
|
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
|
||||||
|
extracted_with_perl=1
|
||||||
else
|
else
|
||||||
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
|
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
|
||||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
|
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
|
||||||
|
|||||||
+4
-315
@@ -303,9 +303,7 @@ func bonusAt(input *util.Chars, idx int) int16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRune(r rune) rune {
|
func normalizeRune(r rune) rune {
|
||||||
// Every key of the map folds to ASCII, so a rune the bitmap rejects cannot
|
if r < 0x00C0 || r > 0xFF61 {
|
||||||
// be in it. TestNormalizedKeysAreFlagged pins that.
|
|
||||||
if !util.MayFoldToAscii(r) {
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,90 +345,10 @@ func isAscii(runes []rune) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// runePrefilterable reports whether scanning the rune array can decide this
|
|
||||||
// pattern against this item without missing a match. Phase 2 lowercases an
|
|
||||||
// uppercase text rune and then normalizes it, and the scan sees neither
|
|
||||||
// transform, so every pattern rune must be unreachable by them.
|
|
||||||
func runePrefilterable(input *util.Chars, pattern []rune, caseSensitive bool) bool {
|
|
||||||
if input.MayFoldToAscii() {
|
|
||||||
// A non-ASCII rune of this item could fold onto an ASCII pattern char
|
|
||||||
for _, r := range pattern {
|
|
||||||
if r < utf8.RuneSelf {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if caseSensitive {
|
|
||||||
// No case transform is applied, and normalization only ever produces
|
|
||||||
// ASCII, so nothing can reach a non-ASCII pattern rune
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, r := range pattern {
|
|
||||||
// Another rune must not lowercase onto this one. Being uncased is not
|
|
||||||
// enough by itself: U+00DF has no simple uppercase yet U+1E9E
|
|
||||||
// lowercases to it. Excluding the foldable set covers that.
|
|
||||||
if r >= utf8.RuneSelf &&
|
|
||||||
(unicode.ToUpper(r) != r || unicode.ToLower(r) != r || util.MayFoldToAscii(r)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when
|
|
||||||
// runePrefilterable says so.
|
|
||||||
func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
|
||||||
runes := input.Runes()
|
|
||||||
firstIdx, idx, lastIdx := 0, 0, 0
|
|
||||||
var last rune
|
|
||||||
for pidx := range pattern {
|
|
||||||
last = pattern[pidx]
|
|
||||||
if last < utf8.RuneSelf {
|
|
||||||
idx = indexAsciiRune(runes, caseSensitive, byte(last), idx)
|
|
||||||
} else {
|
|
||||||
idx = indexRune(runes, last, idx)
|
|
||||||
}
|
|
||||||
if idx < 0 {
|
|
||||||
return -1, -1
|
|
||||||
}
|
|
||||||
if pidx == 0 && idx > 0 {
|
|
||||||
// Step back to find the right bonus point
|
|
||||||
firstIdx = idx - 1
|
|
||||||
}
|
|
||||||
lastIdx = idx
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the last appearance of the last character of the pattern to limit
|
|
||||||
// the search scope
|
|
||||||
if lastIdx+1 < len(runes) {
|
|
||||||
var end int
|
|
||||||
if last < utf8.RuneSelf {
|
|
||||||
end = lastIndexAsciiRune(runes, caseSensitive, byte(last), lastIdx+1)
|
|
||||||
} else {
|
|
||||||
end = lastIndexRune(runes, last, lastIdx+1)
|
|
||||||
}
|
|
||||||
if end >= 0 {
|
|
||||||
return firstIdx, end + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstIdx, lastIdx + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
||||||
|
// Can't determine
|
||||||
if !input.IsBytes() {
|
if !input.IsBytes() {
|
||||||
if disableRunePrefilter {
|
return 0, input.Length()
|
||||||
return 0, input.Length()
|
|
||||||
}
|
|
||||||
// runePrefilterable does not inline, so keep the common case out of
|
|
||||||
// it: an ASCII pattern against an item that cannot fold to ASCII is
|
|
||||||
// always scannable, and both of these checks do inline.
|
|
||||||
if input.MayFoldToAscii() || !isAscii(pattern) {
|
|
||||||
if !runePrefilterable(input, pattern, caseSensitive) {
|
|
||||||
return 0, input.Length()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runeFuzzyIndex(input, pattern, caseSensitive)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not possible
|
// Not possible
|
||||||
@@ -507,218 +425,6 @@ func debugV2(T []rune, pattern []rune, F []int32, lastIdx int, H []int16, C []in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fuzzyMatchV2Single is a fast path for a single-character ASCII pattern on
|
|
||||||
// ASCII input. Same scoring and tiebreaks as Phase 2 of FuzzyMatchV2, but
|
|
||||||
// jumps between occurrences instead of scanning every character, and
|
|
||||||
// allocates no arrays.
|
|
||||||
func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b byte, withPos bool) (Result, *[]int) {
|
|
||||||
byteArray := input.Bytes()
|
|
||||||
maxScore, maxScorePos := int16(0), -1
|
|
||||||
for idx := 0; idx < len(byteArray); {
|
|
||||||
idx = trySkip(input, caseSensitive, b, idx)
|
|
||||||
if idx < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
class := asciiCharClasses[byteArray[idx]]
|
|
||||||
prevClass := initialCharClass
|
|
||||||
if idx > 0 {
|
|
||||||
prevClass = asciiCharClasses[byteArray[idx-1]]
|
|
||||||
}
|
|
||||||
bonus := bonusMatrix[prevClass][class]
|
|
||||||
score := scoreMatch + bonus*bonusFirstCharMultiplier
|
|
||||||
if forward && score > maxScore || !forward && score >= maxScore {
|
|
||||||
maxScore, maxScorePos = score, idx
|
|
||||||
if forward && bonus >= bonusBoundary {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
if maxScorePos < 0 {
|
|
||||||
return Result{-1, -1, 0}, nil
|
|
||||||
}
|
|
||||||
result := Result{maxScorePos, maxScorePos + 1, int(maxScore)}
|
|
||||||
if !withPos {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
pos := []int{maxScorePos}
|
|
||||||
return result, &pos
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test hooks: force the general path instead of a fast path, so the two can
|
|
||||||
// be compared for equivalence.
|
|
||||||
var (
|
|
||||||
disableSingle bool
|
|
||||||
disableTwo bool
|
|
||||||
disableRunePrefilter bool
|
|
||||||
)
|
|
||||||
|
|
||||||
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
|
|
||||||
// ASCII input. It replicates Phase 2 (row 0) and Phase 3 (row 1) of
|
|
||||||
// FuzzyMatchV2 in a single pass, carrying the row-0 diagonal/left values and
|
|
||||||
// the row-1 left value as scalars instead of materializing score arrays.
|
|
||||||
// When withPos is set, the two DP rows are stored so the backtrace can
|
|
||||||
// recover the matched character positions, exactly as the general Phase 4.
|
|
||||||
func fuzzyMatchV2Two(caseSensitive bool, forward bool, input *util.Chars, pchar0 byte, pchar1 byte, minIdx int, maxIdx int, withPos bool, slab *util.Slab) (Result, *[]int) {
|
|
||||||
sl := input.Bytes()
|
|
||||||
N := maxIdx - minIdx
|
|
||||||
|
|
||||||
// Row storage, only needed for the backtrace
|
|
||||||
var H0, C0, H1, C1 []int16
|
|
||||||
if withPos {
|
|
||||||
o := 0
|
|
||||||
o, H0 = alloc16(o, slab, N)
|
|
||||||
o, C0 = alloc16(o, slab, N)
|
|
||||||
o, H1 = alloc16(o, slab, N)
|
|
||||||
_, C1 = alloc16(o, slab, N)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxScore, maxScorePos := int16(0), 0
|
|
||||||
prevClass := initialCharClass
|
|
||||||
|
|
||||||
// Subsequence tracking (equivalent to F[0], F[1] in Phase 2). The scope
|
|
||||||
// from asciiFuzzyIndex ends exactly at the last pchar1, so row 1's upper
|
|
||||||
// bound (Phase 3 lastIdx) is the final loop position; no separate var.
|
|
||||||
f0, f1 := -1, -1
|
|
||||||
|
|
||||||
// Row 0 running state at the previous position
|
|
||||||
var h0Prev, c0Prev, bPrev int16
|
|
||||||
inGap0 := false
|
|
||||||
|
|
||||||
// Row 1 running state
|
|
||||||
var h1Prev int16
|
|
||||||
inGap1 := false
|
|
||||||
|
|
||||||
for off := range N {
|
|
||||||
pos := minIdx + off
|
|
||||||
b := sl[pos]
|
|
||||||
class := asciiCharClasses[b]
|
|
||||||
lb := b
|
|
||||||
if !caseSensitive && b >= 'A' && b <= 'Z' {
|
|
||||||
lb = b + 32
|
|
||||||
}
|
|
||||||
bonus := bonusMatrix[prevClass][class]
|
|
||||||
prevClass = class
|
|
||||||
|
|
||||||
// Subsequence advance: pchar0 then pchar1
|
|
||||||
if f0 < 0 {
|
|
||||||
if lb == pchar0 {
|
|
||||||
f0 = off
|
|
||||||
}
|
|
||||||
} else if lb == pchar1 && f1 < 0 {
|
|
||||||
f1 = off
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 0 (pchar0)
|
|
||||||
var h0Cur, c0Cur int16
|
|
||||||
if lb == pchar0 {
|
|
||||||
h0Cur = scoreMatch + bonus*bonusFirstCharMultiplier
|
|
||||||
c0Cur = 1
|
|
||||||
inGap0 = false
|
|
||||||
} else {
|
|
||||||
if inGap0 {
|
|
||||||
h0Cur = max(h0Prev+scoreGapExtension, 0)
|
|
||||||
} else {
|
|
||||||
h0Cur = max(h0Prev+scoreGapStart, 0)
|
|
||||||
}
|
|
||||||
c0Cur = 0
|
|
||||||
inGap0 = true
|
|
||||||
}
|
|
||||||
if withPos {
|
|
||||||
H0[off], C0[off] = h0Cur, c0Cur
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 1 (pchar1), only within [f1, lastIdx]
|
|
||||||
if f1 >= 0 && off >= f1 {
|
|
||||||
var s1, s2, consecutive int16
|
|
||||||
hleft := h1Prev
|
|
||||||
if off == f1 {
|
|
||||||
hleft = 0
|
|
||||||
}
|
|
||||||
if inGap1 {
|
|
||||||
s2 = hleft + scoreGapExtension
|
|
||||||
} else {
|
|
||||||
s2 = hleft + scoreGapStart
|
|
||||||
}
|
|
||||||
if lb == pchar1 {
|
|
||||||
s1 = h0Prev + scoreMatch
|
|
||||||
bb := bonus
|
|
||||||
consecutive = c0Prev + 1
|
|
||||||
if consecutive > 1 {
|
|
||||||
fb := bPrev
|
|
||||||
if bb >= bonusBoundary && bb > fb {
|
|
||||||
consecutive = 1
|
|
||||||
} else {
|
|
||||||
bb = max(bb, bonusConsecutive, fb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s1+bb < s2 {
|
|
||||||
s1 += bonus
|
|
||||||
consecutive = 0
|
|
||||||
} else {
|
|
||||||
s1 += bb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inGap1 = s1 < s2
|
|
||||||
score := max(s1, s2, 0)
|
|
||||||
if forward && score > maxScore || !forward && score >= maxScore {
|
|
||||||
maxScore, maxScorePos = score, off
|
|
||||||
}
|
|
||||||
h1Prev = score
|
|
||||||
if withPos {
|
|
||||||
H1[off], C1[off] = score, consecutive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h0Prev, c0Prev, bPrev = h0Cur, c0Cur, bonus
|
|
||||||
}
|
|
||||||
|
|
||||||
if f1 < 0 {
|
|
||||||
return Result{-1, -1, 0}, nil
|
|
||||||
}
|
|
||||||
if !withPos {
|
|
||||||
return Result{minIdx + f0, minIdx + maxScorePos + 1, int(maxScore)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4 backtrace, specialized to two rows. Mirrors the general loop:
|
|
||||||
// record a cell when it dominates its diagonal and left neighbors, then
|
|
||||||
// step up a row; otherwise step left. preferMatch breaks score ties and
|
|
||||||
// must not read row 1 left of f1 (unwritten, possibly stale slab data).
|
|
||||||
pos := posArray(true, 2)
|
|
||||||
i := 1
|
|
||||||
j := maxScorePos
|
|
||||||
preferMatch := true
|
|
||||||
for {
|
|
||||||
var s, s1, s2, cCur int16
|
|
||||||
if i == 1 {
|
|
||||||
s, cCur = H1[j], C1[j]
|
|
||||||
if j >= f1 {
|
|
||||||
s1 = H0[j-1]
|
|
||||||
}
|
|
||||||
if j > f1 {
|
|
||||||
s2 = H1[j-1]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
s, cCur = H0[j], C0[j]
|
|
||||||
if j > f0 {
|
|
||||||
s2 = H0[j-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row := i
|
|
||||||
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
|
||||||
*pos = append(*pos, j+minIdx)
|
|
||||||
if i == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
i--
|
|
||||||
}
|
|
||||||
preferMatch = cCur > 1 ||
|
|
||||||
row == 0 && j < N-1 && j+1 >= f1 && C1[j+1] > 0
|
|
||||||
j--
|
|
||||||
}
|
|
||||||
return Result{minIdx + j, minIdx + maxScorePos + 1, int(maxScore)}, pos
|
|
||||||
}
|
|
||||||
|
|
||||||
func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
|
func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
|
||||||
// Assume that pattern is given in lowercase if case-insensitive.
|
// Assume that pattern is given in lowercase if case-insensitive.
|
||||||
// First check if there's a match and calculate bonus for each position.
|
// First check if there's a match and calculate bonus for each position.
|
||||||
@@ -741,12 +447,6 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
|
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-character ASCII pattern needs neither the prefilter nor the
|
|
||||||
// score matrix
|
|
||||||
if !disableSingle && M == 1 && input.IsBytes() && pattern[0] < utf8.RuneSelf {
|
|
||||||
return fuzzyMatchV2Single(caseSensitive, forward, input, byte(pattern[0]), withPos)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1. Optimized search for ASCII string
|
// Phase 1. Optimized search for ASCII string
|
||||||
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
|
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
|
||||||
if minIdx < 0 {
|
if minIdx < 0 {
|
||||||
@@ -755,13 +455,6 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
|
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
|
||||||
N = maxIdx - minIdx
|
N = maxIdx - minIdx
|
||||||
|
|
||||||
// Two-character ASCII pattern: rows 0 and 1 collapse to scalar running
|
|
||||||
// state, so the general score arrays are unnecessary
|
|
||||||
if !disableTwo && M == 2 && input.IsBytes() &&
|
|
||||||
pattern[0] < utf8.RuneSelf && pattern[1] < utf8.RuneSelf {
|
|
||||||
return fuzzyMatchV2Two(caseSensitive, forward, input, byte(pattern[0]), byte(pattern[1]), minIdx, maxIdx, withPos, slab)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
|
// Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
|
||||||
offset16 := 0
|
offset16 := 0
|
||||||
offset32 := 0
|
offset32 := 0
|
||||||
@@ -937,7 +630,6 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
s2 = H[I+j0-1]
|
s2 = H[I+j0-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
row := i
|
|
||||||
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
||||||
*pos = append(*pos, j+minIdx)
|
*pos = append(*pos, j+minIdx)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -945,10 +637,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
}
|
}
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
// Row below is only written from column F[row+1]; don't read
|
preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0
|
||||||
// stale slab data left of it
|
|
||||||
preferMatch = C[I+j0] > 1 ||
|
|
||||||
row+1 < M && j < lastIdx && int32(j+1) >= F[row+1] && C[I+width+j0+1] > 0
|
|
||||||
j--
|
j--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,98 +218,3 @@ func TestLongStringWithNormalize(t *testing.T) {
|
|||||||
unicodeString := string(bytes) + " Minímal example"
|
unicodeString := string(bytes) + " Minímal example"
|
||||||
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
|
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResultPositionsWithReusedSlab(t *testing.T) {
|
|
||||||
// Backtrace positions in equal-score ties must not depend on data
|
|
||||||
// a previous match left in the slab
|
|
||||||
pattern := []rune("co/")
|
|
||||||
target := util.ToChars([]byte("core_color/view/server.txt"))
|
|
||||||
_, freshPos := FuzzyMatchV2(false, false, true, &target, pattern, true, util.MakeSlab(100*1024, 2048))
|
|
||||||
|
|
||||||
slab := util.MakeSlab(100*1024, 2048)
|
|
||||||
dirty := util.ToChars([]byte("completion/keybinding/client/handler/writer_index.txt"))
|
|
||||||
FuzzyMatchV2(false, false, true, &dirty, pattern, true, slab)
|
|
||||||
_, reusedPos := FuzzyMatchV2(false, false, true, &target, pattern, true, slab)
|
|
||||||
|
|
||||||
if len(*freshPos) != len(*reusedPos) {
|
|
||||||
t.Fatalf("position count mismatch: %v vs %v", *freshPos, *reusedPos)
|
|
||||||
}
|
|
||||||
for i := range *freshPos {
|
|
||||||
if (*freshPos)[i] != (*reusedPos)[i] {
|
|
||||||
t.Errorf("positions differ with reused slab: %v vs %v", *freshPos, *reusedPos)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFuzzyMatchV2TwoEquivalence verifies that the two-character fast path
|
|
||||||
// produces the same Result and positions as the general algorithm across
|
|
||||||
// case sensitivity, direction, and withPos, using a reused slab to surface
|
|
||||||
// any stale-data reads in the backtrace.
|
|
||||||
func TestFuzzyMatchV2TwoEquivalence(t *testing.T) {
|
|
||||||
words := []string{"src", "main", "core", "config", "parser", "render", "server",
|
|
||||||
"client", "index", "handler", "util", "list", "cache", "reader"}
|
|
||||||
exts := []string{".go", ".rb", ".py", ".md", ".c", ".txt"}
|
|
||||||
// Deterministic corpus (LCG), plus adversarial short/repeated items
|
|
||||||
corpus := []util.Chars{}
|
|
||||||
seed := uint32(12345)
|
|
||||||
next := func(n int) int { seed = seed*1664525 + 1013904223; return int(seed>>8) % n }
|
|
||||||
for i := 0; i < 4000; i++ {
|
|
||||||
depth := 2 + next(4)
|
|
||||||
s := ""
|
|
||||||
for d := 0; d < depth; d++ {
|
|
||||||
if d > 0 {
|
|
||||||
s += "/"
|
|
||||||
}
|
|
||||||
s += words[next(len(words))]
|
|
||||||
if next(5) == 0 {
|
|
||||||
s += "_" + words[next(len(words))]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s += exts[next(len(exts))]
|
|
||||||
corpus = append(corpus, util.ToChars([]byte(s)))
|
|
||||||
}
|
|
||||||
for _, s := range []string{"", "a", "ab", "aa", "aXb", "a/b", "//", "..", "abcabc",
|
|
||||||
"AaBb", "x.y.z", "a_b_c", "CoreCore", " co"} {
|
|
||||||
corpus = append(corpus, util.ToChars([]byte(s)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pats := []string{"co", "ab", "aa", "//", "..", "sr", "a/", "_c", "oo", "Ab",
|
|
||||||
"z.", "b.", "1a", "ll", "re", "er", "Co"}
|
|
||||||
slab := util.MakeSlab(100*1024, 2048)
|
|
||||||
for _, cs := range []bool{false, true} {
|
|
||||||
for _, fwd := range []bool{true, false} {
|
|
||||||
for _, wp := range []bool{false, true} {
|
|
||||||
for _, p := range pats {
|
|
||||||
pattern := []rune(p)
|
|
||||||
for j := range corpus {
|
|
||||||
disableTwo = true
|
|
||||||
rg, pg := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
|
|
||||||
disableTwo = false
|
|
||||||
rt, pt := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
|
|
||||||
if rg != rt {
|
|
||||||
t.Fatalf("Result cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
|
|
||||||
cs, fwd, wp, p, corpus[j].ToString(), rg, rt)
|
|
||||||
}
|
|
||||||
if (pg == nil) != (pt == nil) || (pg != nil && !equalInts(*pg, *pt)) {
|
|
||||||
t.Fatalf("Pos cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
|
|
||||||
cs, fwd, wp, p, corpus[j].ToString(), pg, pt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func equalInts(a, b []int) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range a {
|
|
||||||
if a[i] != b[i] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
package algo
|
|
||||||
|
|
||||||
// Equivalence tests for the single- and two-character fast paths against the
|
|
||||||
// general FuzzyMatchV2 algorithm, which serves as the oracle.
|
|
||||||
//
|
|
||||||
// Two complementary strategies:
|
|
||||||
// - Exhaustive: every string up to a fixed length over an alphabet that
|
|
||||||
// covers all ASCII character classes, so every local scoring branch is
|
|
||||||
// exercised (not merely sampled).
|
|
||||||
// - Fuzz: coverage-guided, arbitrary length, to reach cases the bounded
|
|
||||||
// exhaustive sweep cannot (e.g. long gaps where a high score decays).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
// One char of each ASCII class that affects scoring: lower, upper, delimiter,
|
|
||||||
// number, non-word, whitespace.
|
|
||||||
var equivAlphabet = []byte{'a', 'B', '/', '1', '_', ' '}
|
|
||||||
|
|
||||||
func samePos(a, b *[]int) bool {
|
|
||||||
if (a == nil) != (b == nil) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if a == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return equalInts(*a, *b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// compareFastPath runs a single input/pattern/params pair through both the
|
|
||||||
// fast path and the general algorithm and fails on any difference.
|
|
||||||
// It toggles the package-global disable* hook, so tests that call it must
|
|
||||||
// not run in parallel.
|
|
||||||
func compareFastPath(t *testing.T, chars *util.Chars, pattern []rune, cs, norm, fwd, wp bool, disable *bool, slab *util.Slab) {
|
|
||||||
t.Helper()
|
|
||||||
*disable = true
|
|
||||||
rg, pg := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
|
|
||||||
*disable = false
|
|
||||||
rt, pt := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
|
|
||||||
if rg != rt || !samePos(pg, pt) {
|
|
||||||
t.Fatalf("mismatch item=%q pat=%q cs=%v norm=%v fwd=%v wp=%v: general %v %v vs fast %v %v",
|
|
||||||
chars.ToString(), string(pattern), cs, norm, fwd, wp, rg, pg, rt, pt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lowerPattern lowercases the pattern for case-insensitive search, matching
|
|
||||||
// the Algo contract (the pattern is pre-lowercased by BuildPattern).
|
|
||||||
func lowerPattern(p []rune, cs bool) []rune {
|
|
||||||
if cs {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
out := make([]rune, len(p))
|
|
||||||
for i, c := range p {
|
|
||||||
if c >= 'A' && c <= 'Z' {
|
|
||||||
c += 32
|
|
||||||
}
|
|
||||||
out[i] = c
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func runExhaustive(t *testing.T, patLen, maxLen int, disable *bool) {
|
|
||||||
// All patterns of length patLen over the alphabet
|
|
||||||
var pats [][]rune
|
|
||||||
var genPat func(cur []rune)
|
|
||||||
genPat = func(cur []rune) {
|
|
||||||
if len(cur) == patLen {
|
|
||||||
pats = append(pats, append([]rune(nil), cur...))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range equivAlphabet {
|
|
||||||
genPat(append(cur, rune(c)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
genPat(nil)
|
|
||||||
|
|
||||||
slab := util.MakeSlab(100*1024, 2048)
|
|
||||||
buf := make([]byte, 0, maxLen)
|
|
||||||
var rec func(depth int)
|
|
||||||
rec = func(depth int) {
|
|
||||||
chars := util.ToChars(append([]byte(nil), buf...))
|
|
||||||
// normalize is not varied: normalizeRune is the identity below 0xC0,
|
|
||||||
// and the general algorithm skips normalization on its ASCII branch,
|
|
||||||
// so it cannot change the result for the ASCII-only fast path. The
|
|
||||||
// fuzz test exercises normalize=true as a guard against that changing.
|
|
||||||
for _, cs := range []bool{false, true} {
|
|
||||||
for _, fwd := range []bool{true, false} {
|
|
||||||
for _, wp := range []bool{false, true} {
|
|
||||||
for _, p := range pats {
|
|
||||||
compareFastPath(t, &chars, lowerPattern(p, cs), cs, false, fwd, wp, disable, slab)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if depth == maxLen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range equivAlphabet {
|
|
||||||
buf = append(buf, c)
|
|
||||||
rec(depth + 1)
|
|
||||||
buf = buf[:len(buf)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rec(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFuzzyMatchV2SingleExhaustive(t *testing.T) {
|
|
||||||
runExhaustive(t, 1, 6, &disableSingle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFuzzyMatchV2TwoExhaustive(t *testing.T) {
|
|
||||||
runExhaustive(t, 2, 6, &disableTwo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fuzzFastPath(f *testing.F, patLen int, disable *bool) {
|
|
||||||
inputs := []string{"core_color/view/server.txt", "a b",
|
|
||||||
"XyZ/123_abc.def", "aaaaaaaaaaaaaaaaaaaaaaaa"}
|
|
||||||
// Seed patterns of the right length; wrong-length seeds are rejected below
|
|
||||||
// and would waste the corpus.
|
|
||||||
seedPats := map[int][]string{1: {"c", "a", "/"}, 2: {"co", "ab", "z1", "aa"}}[patLen]
|
|
||||||
for _, in := range inputs {
|
|
||||||
for _, p := range seedPats {
|
|
||||||
f.Add(in, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slab := util.MakeSlab(200*1024, 4096)
|
|
||||||
f.Fuzz(func(t *testing.T, input, pat string) {
|
|
||||||
r := []rune(pat)
|
|
||||||
if len(r) != patLen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range r {
|
|
||||||
if c >= 128 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chars := util.ToChars([]byte(input))
|
|
||||||
if !chars.IsBytes() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, cs := range []bool{false, true} {
|
|
||||||
for _, norm := range []bool{false, true} {
|
|
||||||
for _, fwd := range []bool{true, false} {
|
|
||||||
for _, wp := range []bool{false, true} {
|
|
||||||
compareFastPath(t, &chars, lowerPattern(r, cs), cs, norm, fwd, wp, disable, slab)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func FuzzFuzzyMatchV2Single(f *testing.F) { fuzzFastPath(f, 1, &disableSingle) }
|
|
||||||
func FuzzFuzzyMatchV2Two(f *testing.F) { fuzzFastPath(f, 2, &disableTwo) }
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
//go:build !386 && !amd64 && !arm64
|
|
||||||
|
|
||||||
package algo
|
|
||||||
|
|
||||||
// The byte-view scanners in runeindex_x86.go reinterpret a []rune as
|
|
||||||
// little-endian 4-byte lanes, which is not valid everywhere. Elsewhere the
|
|
||||||
// reference scanners are the implementation.
|
|
||||||
|
|
||||||
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
|
||||||
return indexAsciiRuneRef(runes, caseSensitive, b, from)
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
|
||||||
return lastIndexAsciiRuneRef(runes, caseSensitive, b, from)
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexRune(runes []rune, r rune, from int) int {
|
|
||||||
return indexRuneRef(runes, r, from)
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastIndexRune(runes []rune, r rune, from int) int {
|
|
||||||
return lastIndexRuneRef(runes, r, from)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package algo
|
|
||||||
|
|
||||||
// Reference scanners over a []rune, with no representation tricks.
|
|
||||||
//
|
|
||||||
// They have two roles. Where reinterpreting a []rune as little-endian bytes is
|
|
||||||
// not valid, they are the shipped implementation, via runeindex_others.go.
|
|
||||||
// Everywhere else, the tests feed the same inputs to these and to the byte-view
|
|
||||||
// scanners in runeindex_x86.go and require identical answers.
|
|
||||||
//
|
|
||||||
// They carry no build tag so that both roles hold on every platform. Otherwise
|
|
||||||
// the portable build would be code that nothing here ever runs.
|
|
||||||
|
|
||||||
func indexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
|
|
||||||
lower, upper := rune(b), rune(-1)
|
|
||||||
if !caseSensitive && b >= 'a' && b <= 'z' {
|
|
||||||
upper = rune(b - 32)
|
|
||||||
}
|
|
||||||
for i := from; i < len(runes); i++ {
|
|
||||||
if runes[i] == lower || runes[i] == upper {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
|
|
||||||
lower, upper := rune(b), rune(-1)
|
|
||||||
if !caseSensitive && b >= 'a' && b <= 'z' {
|
|
||||||
upper = rune(b - 32)
|
|
||||||
}
|
|
||||||
for i := len(runes) - 1; i >= from; i-- {
|
|
||||||
if runes[i] == lower || runes[i] == upper {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexRuneRef(runes []rune, r rune, from int) int {
|
|
||||||
for i := from; i < len(runes); i++ {
|
|
||||||
if runes[i] == r {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastIndexRuneRef(runes []rune, r rune, from int) int {
|
|
||||||
for i := len(runes) - 1; i >= from; i-- {
|
|
||||||
if runes[i] == r {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
//go:build 386 || amd64 || arm64
|
|
||||||
|
|
||||||
package algo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// On these architectures a []rune is a little-endian array of 4-byte lanes, so
|
|
||||||
// an ASCII rune is the byte itself followed by three zero bytes at a 4-byte
|
|
||||||
// aligned offset. That lets the SIMD byte scanners run over the rune array
|
|
||||||
// directly: find the low byte, then confirm alignment and the three zeroes.
|
|
||||||
// A byte equal to the needle can also appear as the low byte of a multi-byte
|
|
||||||
// rune (0x0165 has low byte 'e'), which those two checks reject.
|
|
||||||
|
|
||||||
func runeBytes(runes []rune) []byte {
|
|
||||||
return unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(runes))), len(runes)*4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexAsciiRune returns the index of the first rune equal to b, or to its
|
|
||||||
// uppercase form when ignoring case, at or after rune index from.
|
|
||||||
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
|
||||||
view := runeBytes(runes)
|
|
||||||
both := !caseSensitive && b >= 'a' && b <= 'z'
|
|
||||||
for off := from * 4; off < len(view); {
|
|
||||||
var idx int
|
|
||||||
if both {
|
|
||||||
idx = IndexByteTwo(view[off:], b, b-32)
|
|
||||||
} else {
|
|
||||||
idx = bytes.IndexByte(view[off:], b)
|
|
||||||
}
|
|
||||||
if idx < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
pos := off + idx
|
|
||||||
if pos&3 == 0 && view[pos+1]|view[pos+2]|view[pos+3] == 0 {
|
|
||||||
return pos >> 2
|
|
||||||
}
|
|
||||||
off = pos + 1
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// runeNeedle picks which of the rune's four bytes to scan for, and returns its
|
|
||||||
// lane index and value. A zero byte is a useless needle because every ASCII
|
|
||||||
// rune contributes three of them, so U+AE00 scanned by its low byte would hit
|
|
||||||
// on almost every character of an ASCII-heavy line. Prefer a byte that cannot
|
|
||||||
// occur in an ASCII rune at all, then any non-zero byte.
|
|
||||||
func runeNeedle(r rune) (int, byte) {
|
|
||||||
var b [4]byte
|
|
||||||
b[0], b[1], b[2], b[3] = byte(r), byte(r>>8), byte(r>>16), byte(r>>24)
|
|
||||||
for i, v := range b {
|
|
||||||
if v >= 0x80 {
|
|
||||||
return i, v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, v := range b {
|
|
||||||
if v != 0 {
|
|
||||||
return i, v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func runeAt(view []byte, start int) rune {
|
|
||||||
return rune(view[start]) | rune(view[start+1])<<8 | rune(view[start+2])<<16 | rune(view[start+3])<<24
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexRune returns the index of the first rune equal to r at or after rune
|
|
||||||
// index from. Case is not folded, so the caller must have established that no
|
|
||||||
// other rune can transform into r.
|
|
||||||
func indexRune(runes []rune, r rune, from int) int {
|
|
||||||
view := runeBytes(runes)
|
|
||||||
lane, needle := runeNeedle(r)
|
|
||||||
for off := from*4 + lane; off < len(view); {
|
|
||||||
idx := bytes.IndexByte(view[off:], needle)
|
|
||||||
if idx < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
pos := off + idx
|
|
||||||
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
|
|
||||||
return start >> 2
|
|
||||||
}
|
|
||||||
off = pos + 1
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastIndexRune is indexRune scanning backwards from the end.
|
|
||||||
func lastIndexRune(runes []rune, r rune, from int) int {
|
|
||||||
view := runeBytes(runes)
|
|
||||||
lane, needle := runeNeedle(r)
|
|
||||||
for end := len(view); end > from*4+lane; {
|
|
||||||
idx := bytes.LastIndexByte(view[from*4+lane:end], needle)
|
|
||||||
if idx < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
pos := from*4 + lane + idx
|
|
||||||
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
|
|
||||||
return start >> 2
|
|
||||||
}
|
|
||||||
end = pos
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastIndexAsciiRune is indexAsciiRune scanning backwards from the end.
|
|
||||||
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
|
||||||
view := runeBytes(runes)[from*4:]
|
|
||||||
both := !caseSensitive && b >= 'a' && b <= 'z'
|
|
||||||
for end := len(view); end > 0; {
|
|
||||||
var idx int
|
|
||||||
if both {
|
|
||||||
idx = lastIndexByteTwo(view[:end], b, b-32)
|
|
||||||
} else {
|
|
||||||
idx = bytes.LastIndexByte(view[:end], b)
|
|
||||||
}
|
|
||||||
if idx < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if idx&3 == 0 && view[idx+1]|view[idx+2]|view[idx+3] == 0 {
|
|
||||||
return from + idx>>2
|
|
||||||
}
|
|
||||||
end = idx
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
package algo
|
|
||||||
|
|
||||||
// Correctness tests for the rune-array prefilter (Step C).
|
|
||||||
//
|
|
||||||
// The prefilter may narrow the search scope but must never change a Result or
|
|
||||||
// its positions, and must never reject an item the general path would match.
|
|
||||||
// Each result is compared against the same code with the prefilter disabled.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
// foldForTest mirrors what Phase 2 does to a non-ASCII text rune: lowercase if
|
|
||||||
// uppercase, then normalize.
|
|
||||||
func foldForTest(r rune, normalize bool) rune {
|
|
||||||
if charClassOfNonAscii(r) == charUpper {
|
|
||||||
r = unicode.To(unicode.LowerCase, r)
|
|
||||||
}
|
|
||||||
if normalize {
|
|
||||||
r = normalizeRune(r)
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// The prefilter is only safe on items whose runes cannot become ASCII. This
|
|
||||||
// pins util.MayFoldToAscii as a superset of the runes that actually can, over
|
|
||||||
// the whole Unicode range and both normalization modes. If normalize.go or the
|
|
||||||
// Go unicode tables change, this fails.
|
|
||||||
func TestMayFoldToAsciiIsSuperset(t *testing.T) {
|
|
||||||
missed := 0
|
|
||||||
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
|
|
||||||
if r >= 0xD800 && r <= 0xDFFF {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, normalize := range []bool{true, false} {
|
|
||||||
if foldForTest(r, normalize) < utf8.RuneSelf && !util.MayFoldToAscii(r) {
|
|
||||||
if missed++; missed < 10 {
|
|
||||||
t.Errorf("U+%04X folds to ASCII (normalize=%v) but MayFoldToAscii is false", r, normalize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if missed > 0 {
|
|
||||||
t.Fatalf("%d runes fold to ASCII without being flagged", missed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scripts that must stay unflagged, otherwise the prefilter never engages for
|
|
||||||
// them and Step C buys nothing.
|
|
||||||
func TestMayFoldToAsciiExcludesMajorScripts(t *testing.T) {
|
|
||||||
for _, s := range []struct {
|
|
||||||
name string
|
|
||||||
lo, hi rune
|
|
||||||
}{
|
|
||||||
{"Cyrillic", 0x0400, 0x04FF}, {"Greek", 0x0370, 0x03FF}, {"Hebrew", 0x0590, 0x05FF},
|
|
||||||
{"Arabic", 0x0600, 0x06FF}, {"Thai", 0x0E00, 0x0E7F}, {"Devanagari", 0x0900, 0x097F},
|
|
||||||
{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
|
|
||||||
{"box drawing", 0x2500, 0x257F}, {"emoji", 0x1F300, 0x1FAFF},
|
|
||||||
// These sit between the Latin blocks and were swallowed by an earlier,
|
|
||||||
// wider grouping of foldableRanges. General Punctuation is the costly
|
|
||||||
// one: curly quotes, en and em dashes and the ellipsis live there.
|
|
||||||
{"Greek Extended", 0x1F00, 0x1FFF}, {"General Punctuation", 0x2000, 0x206F},
|
|
||||||
{"Currency Symbols", 0x20A0, 0x20CF}, {"CJK Symbols", 0x3000, 0x303F},
|
|
||||||
} {
|
|
||||||
for r := s.lo; r <= s.hi; r++ {
|
|
||||||
if util.MayFoldToAscii(r) {
|
|
||||||
t.Errorf("%s U+%04X should not be flagged foldable", s.name, r)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The byte-view scan must agree with the shipped reference scanners. The interesting inputs
|
|
||||||
// are runes whose low byte collides with the needle (U+0165 has low byte 'e')
|
|
||||||
// and runes sharing a lane offset, which the alignment and zero checks reject.
|
|
||||||
func TestIndexAsciiRuneMatchesReference(t *testing.T) {
|
|
||||||
rng := rand.New(rand.NewSource(3))
|
|
||||||
alphabet := []rune{'a', 'A', 'e', 'E', '/', '1', 0x0165, 0x00E9, 0x4E00, 0xD55C,
|
|
||||||
0x1F389, 0x0065 + 0x100, 0x0041 + 0x100, 0x2F65}
|
|
||||||
for trial := range 20000 {
|
|
||||||
n := rng.Intn(24)
|
|
||||||
runes := make([]rune, n)
|
|
||||||
for i := range runes {
|
|
||||||
runes[i] = alphabet[rng.Intn(len(alphabet))]
|
|
||||||
}
|
|
||||||
b := []byte{'a', 'e', 'A', 'E', '/', '1'}[rng.Intn(6)]
|
|
||||||
cs := rng.Intn(2) == 0
|
|
||||||
from := 0
|
|
||||||
if n > 0 {
|
|
||||||
from = rng.Intn(n)
|
|
||||||
}
|
|
||||||
if got, exp := indexAsciiRune(runes, cs, b, from), indexAsciiRuneRef(runes, cs, b, from); got != exp {
|
|
||||||
t.Fatalf("trial %d: indexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
|
|
||||||
}
|
|
||||||
if got, exp := lastIndexAsciiRune(runes, cs, b, from), lastIndexAsciiRuneRef(runes, cs, b, from); got != exp {
|
|
||||||
t.Fatalf("trial %d: lastIndexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Differential test: the prefilter must not change any Result or position.
|
|
||||||
// Corpora deliberately mix scripts that clear the foldable bit (CJK, Hangul,
|
|
||||||
// Cyrillic, emoji) with scripts that set it (accented Latin, fullwidth), so
|
|
||||||
// both the engaged and the bypassed path are exercised.
|
|
||||||
func TestRunePrefilterEquivalence(t *testing.T) {
|
|
||||||
t.Cleanup(func() { disableRunePrefilter = false })
|
|
||||||
rng := rand.New(rand.NewSource(4))
|
|
||||||
parts := []string{
|
|
||||||
"src", "util", "conf", "a", "e", "E", "A", "/", "_", "1", " ",
|
|
||||||
"漢字", "한글", "мир", "ελλ", "🎉", "café", "Müller", "naïve", "full",
|
|
||||||
"Å", "İ", "K", "ǰ", "ff",
|
|
||||||
}
|
|
||||||
patterns := []string{"a", "e", "conf", "src/util", "ae", "A", "E", "K", "k", "i", "//", "zz", "s l",
|
|
||||||
// non-ASCII patterns, the Step G path
|
|
||||||
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "f",
|
|
||||||
"漢a", "a漢", "한글/src", "🎉e"}
|
|
||||||
|
|
||||||
slab := util.MakeSlab(100*1024, 2048)
|
|
||||||
engaged, bypassed := 0, 0
|
|
||||||
|
|
||||||
for trial := range 30000 {
|
|
||||||
var sb strings.Builder
|
|
||||||
for range 1 + rng.Intn(8) {
|
|
||||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
|
||||||
}
|
|
||||||
chars := util.ToChars([]byte(sb.String()))
|
|
||||||
if chars.IsBytes() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if chars.MayFoldToAscii() {
|
|
||||||
bypassed++
|
|
||||||
} else {
|
|
||||||
engaged++
|
|
||||||
}
|
|
||||||
pat := patterns[rng.Intn(len(patterns))]
|
|
||||||
cs := rng.Intn(2) == 0
|
|
||||||
if !cs {
|
|
||||||
pat = strings.ToLower(pat)
|
|
||||||
}
|
|
||||||
pattern := []rune(pat)
|
|
||||||
norm := rng.Intn(2) == 0
|
|
||||||
fwd := rng.Intn(2) == 0
|
|
||||||
wp := rng.Intn(2) == 0
|
|
||||||
|
|
||||||
disableRunePrefilter = true
|
|
||||||
expR, expP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
|
|
||||||
disableRunePrefilter = false
|
|
||||||
gotR, gotP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
|
|
||||||
|
|
||||||
if gotR != expR || !samePos(gotP, expP) {
|
|
||||||
t.Fatalf("trial %d: %q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
|
|
||||||
trial, sb.String(), pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
disableRunePrefilter = false
|
|
||||||
t.Logf("prefilter engaged on %d items, bypassed on %d", engaged, bypassed)
|
|
||||||
if engaged == 0 || bypassed == 0 {
|
|
||||||
t.Fatalf("corpus did not exercise both paths (engaged=%d bypassed=%d)", engaged, bypassed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equivalence alone would still hold if the prefilter never filtered
|
|
||||||
// anything, so confirm it both rejects and narrows.
|
|
||||||
rejected, narrowed := 0, 0
|
|
||||||
for range 5000 {
|
|
||||||
var sb strings.Builder
|
|
||||||
for range 1 + rng.Intn(8) {
|
|
||||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
|
||||||
}
|
|
||||||
chars := util.ToChars([]byte(sb.String()))
|
|
||||||
if chars.IsBytes() || chars.MayFoldToAscii() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pattern := []rune(patterns[rng.Intn(len(patterns))])
|
|
||||||
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
|
|
||||||
switch {
|
|
||||||
case lo < 0:
|
|
||||||
rejected++
|
|
||||||
case hi-lo < chars.Length():
|
|
||||||
narrowed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("prefilter rejected %d items, narrowed scope on %d", rejected, narrowed)
|
|
||||||
if rejected == 0 {
|
|
||||||
t.Fatal("prefilter never rejected an item, so equivalence proves nothing")
|
|
||||||
}
|
|
||||||
if narrowed == 0 {
|
|
||||||
t.Fatal("prefilter never narrowed the scope")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuzzyMatchV1 shares asciiFuzzyIndex, so it needs the same guarantee.
|
|
||||||
func TestRunePrefilterEquivalenceV1(t *testing.T) {
|
|
||||||
t.Cleanup(func() { disableRunePrefilter = false })
|
|
||||||
rng := rand.New(rand.NewSource(5))
|
|
||||||
parts := []string{"src", "conf", "a", "e", "/", "漢字", "한글", "мир", "café", "Å", "🎉"}
|
|
||||||
slab := util.MakeSlab(100*1024, 2048)
|
|
||||||
for trial := range 20000 {
|
|
||||||
var sb strings.Builder
|
|
||||||
for range 1 + rng.Intn(6) {
|
|
||||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
|
||||||
}
|
|
||||||
chars := util.ToChars([]byte(sb.String()))
|
|
||||||
if chars.IsBytes() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pattern := []rune([]string{"a", "e", "conf", "src", "ae", "zz"}[rng.Intn(6)])
|
|
||||||
cs, norm, fwd, wp := rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0
|
|
||||||
|
|
||||||
disableRunePrefilter = true
|
|
||||||
expR, expP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
|
|
||||||
disableRunePrefilter = false
|
|
||||||
gotR, gotP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
|
|
||||||
|
|
||||||
if gotR != expR || !samePos(gotP, expP) {
|
|
||||||
t.Fatalf("trial %d: %q pattern=%q\n prefilter on: %v %v\n prefilter off: %v %v",
|
|
||||||
trial, sb.String(), string(pattern), gotR, gotP, expR, expP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
disableRunePrefilter = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeRune skips the map when util.MayFoldToAscii rejects the rune. That
|
|
||||||
// is only sound if every key of the map is flagged, since a flagged-false rune
|
|
||||||
// is returned unchanged.
|
|
||||||
func TestNormalizedKeysAreFlagged(t *testing.T) {
|
|
||||||
for k := range normalized {
|
|
||||||
if !util.MayFoldToAscii(k) {
|
|
||||||
t.Errorf("normalized key U+%04X (%c) is not flagged by MayFoldToAscii", k, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarding normalizeRune must not change what it returns, for any rune.
|
|
||||||
func TestNormalizeRuneUnchangedByGuard(t *testing.T) {
|
|
||||||
for r := rune(0); r <= unicode.MaxRune; r++ {
|
|
||||||
if r >= 0xD800 && r <= 0xDFFF {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
exp := r
|
|
||||||
if r >= 0x00C0 && r <= 0xFF61 {
|
|
||||||
if n := normalized[r]; n > 0 {
|
|
||||||
exp = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := normalizeRune(r); got != exp {
|
|
||||||
t.Fatalf("normalizeRune(U+%04X) = U+%04X, expected U+%04X", r, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step G lets non-ASCII pattern runes use the scan, but only when no other
|
|
||||||
// rune can transform into them. Being uncased is not sufficient: U+00DF has no
|
|
||||||
// simple uppercase yet U+1E9E lowercases onto it. This pins the guard against
|
|
||||||
// the full preimage relation over all of Unicode.
|
|
||||||
func TestRunePrefilterableGuardIsSound(t *testing.T) {
|
|
||||||
preimage := map[rune][]rune{}
|
|
||||||
for r := rune(0); r <= unicode.MaxRune; r++ {
|
|
||||||
if r >= 0xD800 && r <= 0xDFFF {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if charClassOfNonAscii(r) == charUpper {
|
|
||||||
if l := unicode.To(unicode.LowerCase, r); l != r {
|
|
||||||
preimage[l] = append(preimage[l], r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clean := util.ToChars([]byte("漢字")) // rune mode, fold bit clear
|
|
||||||
admitted, violations := 0, 0
|
|
||||||
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
|
|
||||||
if r >= 0xD800 && r <= 0xDFFF {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !runePrefilterable(&clean, []rune{r}, false) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
admitted++
|
|
||||||
if extra := preimage[r]; len(extra) > 0 {
|
|
||||||
violations++
|
|
||||||
if violations <= 5 {
|
|
||||||
t.Errorf("guard admits U+%04X but %U lowercases onto it", r, extra)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("guard admits %d non-ASCII pattern runes, unsound for %d", admitted, violations)
|
|
||||||
|
|
||||||
// The scripts this step exists for must be fully admitted.
|
|
||||||
for _, s := range []struct {
|
|
||||||
name string
|
|
||||||
lo, hi rune
|
|
||||||
}{{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
|
|
||||||
{"Thai", 0x0E00, 0x0E7F}, {"emoji", 0x1F300, 0x1FAFF}} {
|
|
||||||
for r := s.lo; r <= s.hi; r++ {
|
|
||||||
if !runePrefilterable(&clean, []rune{r}, false) {
|
|
||||||
t.Errorf("%s U+%04X should be admitted", s.name, r)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Step G path must actually engage and reject, otherwise the equivalence
|
|
||||||
// test above proves nothing about non-ASCII patterns.
|
|
||||||
func TestNonAsciiPatternPrefilterEngages(t *testing.T) {
|
|
||||||
rng := rand.New(rand.NewSource(6))
|
|
||||||
parts := []string{"漢字", "한글", "src", "conf", "/", "мир", "🎉"}
|
|
||||||
engaged, rejected, narrowed, bypassed := 0, 0, 0, 0
|
|
||||||
for range 5000 {
|
|
||||||
var sb strings.Builder
|
|
||||||
for range 1 + rng.Intn(6) {
|
|
||||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
|
||||||
}
|
|
||||||
chars := util.ToChars([]byte(sb.String()))
|
|
||||||
if chars.IsBytes() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pattern := []rune([]string{"漢", "漢字", "한글", "мир", "🎉", "é", "ß"}[rng.Intn(7)])
|
|
||||||
if !runePrefilterable(&chars, pattern, false) {
|
|
||||||
bypassed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
engaged++
|
|
||||||
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
|
|
||||||
switch {
|
|
||||||
case lo < 0:
|
|
||||||
rejected++
|
|
||||||
case hi-lo < chars.Length():
|
|
||||||
narrowed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("non-ASCII patterns: engaged %d, bypassed %d, rejected %d, narrowed %d",
|
|
||||||
engaged, bypassed, rejected, narrowed)
|
|
||||||
if engaged == 0 || rejected == 0 {
|
|
||||||
t.Fatalf("non-ASCII pattern path not exercised (engaged=%d rejected=%d)", engaged, rejected)
|
|
||||||
}
|
|
||||||
if bypassed == 0 {
|
|
||||||
t.Fatal("cased and foldable patterns should still bypass")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexRune picks which byte lane to scan, and is checked against the shipped
|
|
||||||
// reference scanners rather than a copy of them. Zero-low-byte runes (U+AE00) and
|
|
||||||
// runes whose lanes collide with common ASCII bytes are the cases that break a
|
|
||||||
// naive low-byte scan, so the alphabet includes both.
|
|
||||||
func TestIndexRuneMatchesReference(t *testing.T) {
|
|
||||||
|
|
||||||
alphabet := []rune{
|
|
||||||
'a', 'e', 'N', '/', 0x00,
|
|
||||||
0xAE00, 0xAC00, 0xD55C, // Hangul, low byte zero for U+AE00
|
|
||||||
0x4E00, 0x6587, 0x9FFF, // CJK, U+4E00 has zero low byte
|
|
||||||
0x3040, 0x0E00, // kana, Thai with zero low byte
|
|
||||||
0x1F389, 0x1F300, // emoji, 3 significant bytes
|
|
||||||
0x0100, 0x0165, 0x00E9, // low byte zero / ASCII-colliding lanes
|
|
||||||
}
|
|
||||||
rng := rand.New(rand.NewSource(7))
|
|
||||||
for trial := range 30000 {
|
|
||||||
n := rng.Intn(20)
|
|
||||||
runes := make([]rune, n)
|
|
||||||
for i := range runes {
|
|
||||||
runes[i] = alphabet[rng.Intn(len(alphabet))]
|
|
||||||
}
|
|
||||||
r := alphabet[rng.Intn(len(alphabet))]
|
|
||||||
from := 0
|
|
||||||
if n > 0 {
|
|
||||||
from = rng.Intn(n)
|
|
||||||
}
|
|
||||||
if got, exp := indexRune(runes, r, from), indexRuneRef(runes, r, from); got != exp {
|
|
||||||
t.Fatalf("trial %d: indexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
|
|
||||||
}
|
|
||||||
if got, exp := lastIndexRune(runes, r, from), lastIndexRuneRef(runes, r, from); got != exp {
|
|
||||||
t.Fatalf("trial %d: lastIndexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// preparePattern mirrors what pattern.go guarantees the algo functions:
|
|
||||||
// lowercased when case-insensitive, normalized when normalize is on.
|
|
||||||
func preparePattern(pat string, caseSensitive, normalize bool) []rune {
|
|
||||||
if !caseSensitive {
|
|
||||||
pat = strings.ToLower(pat)
|
|
||||||
}
|
|
||||||
r := []rune(pat)
|
|
||||||
if normalize {
|
|
||||||
r = NormalizeRunes(r)
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuzzRunePrefilter drives arbitrary rune-mode input and arbitrary patterns
|
|
||||||
// through the prefilter and through the same code with it disabled, and
|
|
||||||
// requires identical Results and positions. The existing fast-path fuzzers
|
|
||||||
// only generate byte-mode input, so they never reach this path.
|
|
||||||
func FuzzRunePrefilter(f *testing.F) {
|
|
||||||
for _, in := range []string{
|
|
||||||
"한글/src/util.go", "漢字/conf", "café/binutils", "мир/test", "🎉/a",
|
|
||||||
"ABC.txt", "Ångström", "ǰ/ß/İ", "a漢b한c", "Āā",
|
|
||||||
} {
|
|
||||||
for _, p := range []string{"a", "conf", "漢", "한글", "мир", "ß", "É", "a漢"} {
|
|
||||||
f.Add(in, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slab := util.MakeSlab(200*1024, 4096)
|
|
||||||
f.Fuzz(func(t *testing.T, input, pat string) {
|
|
||||||
if len(input) > 512 || len(pat) == 0 || len(pat) > 32 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chars := util.ToChars([]byte(input))
|
|
||||||
if chars.IsBytes() {
|
|
||||||
return // byte mode is the existing fuzzers' territory
|
|
||||||
}
|
|
||||||
for _, cs := range []bool{false, true} {
|
|
||||||
for _, norm := range []bool{false, true} {
|
|
||||||
p := preparePattern(pat, cs, norm)
|
|
||||||
if len(p) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, fwd := range []bool{true, false} {
|
|
||||||
for _, wp := range []bool{false, true} {
|
|
||||||
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive} {
|
|
||||||
disableRunePrefilter = true
|
|
||||||
expR, expP := fn(cs, norm, fwd, &chars, p, wp, slab)
|
|
||||||
disableRunePrefilter = false
|
|
||||||
gotR, gotP := fn(cs, norm, fwd, &chars, p, wp, slab)
|
|
||||||
if gotR != expR || !samePos(gotP, expP) {
|
|
||||||
t.Fatalf("input=%q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
|
|
||||||
input, pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunesToChars can produce rune-mode Chars holding zero runes, which sends a
|
|
||||||
// nil pointer through unsafe.SliceData in runeBytes. ToChars cannot produce
|
|
||||||
// this (an empty input is byte mode), so it needs its own test.
|
|
||||||
func TestEmptyRuneModeChars(t *testing.T) {
|
|
||||||
t.Cleanup(func() { disableRunePrefilter = false })
|
|
||||||
slab := util.MakeSlab(100*1024, 2048)
|
|
||||||
for _, runes := range [][]rune{{}, nil, {'a'}, {0x4E00}} {
|
|
||||||
chars := util.RunesToChars(runes)
|
|
||||||
if chars.IsBytes() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pat := range []string{"a", "漢", "ab"} {
|
|
||||||
p := []rune(pat)
|
|
||||||
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive,
|
|
||||||
PrefixMatch, SuffixMatch, EqualMatch} {
|
|
||||||
disableRunePrefilter = true
|
|
||||||
expR, expP := fn(false, true, true, &chars, p, true, slab)
|
|
||||||
disableRunePrefilter = false
|
|
||||||
gotR, gotP := fn(false, true, true, &chars, p, true, slab)
|
|
||||||
if gotR != expR || !samePos(gotP, expP) {
|
|
||||||
t.Errorf("runes=%U pat=%q: prefilter on %v %v, off %v %v", runes, pat, gotR, gotP, expR, expP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MayFoldToAscii subtracts before bounds-checking, so a rune below the range
|
|
||||||
// (including a negative one, which utf8 decoding never produces but callers
|
|
||||||
// could construct) must not wrap into a false positive.
|
|
||||||
func TestMayFoldToAsciiOutOfRange(t *testing.T) {
|
|
||||||
for _, r := range []rune{-1, -0x10000, 0, 'a', 0x7F, 0xBF, 0xFF62, unicode.MaxRune, unicode.MaxRune + 1} {
|
|
||||||
if util.MayFoldToAscii(r) {
|
|
||||||
t.Errorf("MayFoldToAscii(%d) = true, expected false", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/tui"
|
"github.com/junegunn/fzf/src/tui"
|
||||||
@@ -20,75 +19,10 @@ import (
|
|||||||
|
|
||||||
const becomeSuffix = ".become"
|
const becomeSuffix = ".become"
|
||||||
|
|
||||||
// Withheld from the environment replay below, so an outer value cannot
|
|
||||||
// override what the floating pane set
|
|
||||||
const internalEnvPrefix = "__FZF_INTERNAL_"
|
|
||||||
|
|
||||||
func escapeSingleQuote(str string) string {
|
func escapeSingleQuote(str string) string {
|
||||||
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
|
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and remove, so preview and execute commands do not inherit it
|
|
||||||
func takeEnv(name string) string {
|
|
||||||
value := os.Getenv(name)
|
|
||||||
os.Unsetenv(name)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Applies label updates off the event loop, which would block on the fork
|
|
||||||
type labelUpdater struct {
|
|
||||||
set func(string)
|
|
||||||
mu sync.Mutex
|
|
||||||
pending *string
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// A queue of one: a burst collapses to the newest label. A goroutine per
|
|
||||||
// update could apply them out of order
|
|
||||||
func (u *labelUpdater) update(label string) {
|
|
||||||
u.mu.Lock()
|
|
||||||
defer u.mu.Unlock()
|
|
||||||
u.pending = &label
|
|
||||||
if u.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.running = true
|
|
||||||
go u.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends once caught up, so nothing needs cancelling when Run returns
|
|
||||||
func (u *labelUpdater) run() {
|
|
||||||
for {
|
|
||||||
u.mu.Lock()
|
|
||||||
label := u.pending
|
|
||||||
if label == nil {
|
|
||||||
u.running = false
|
|
||||||
u.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.pending = nil
|
|
||||||
u.mu.Unlock()
|
|
||||||
u.set(*label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates the label on the native border, or nil when it is not fzf's to update
|
|
||||||
func nativeLabelSetter() func(string) {
|
|
||||||
// Read both, so neither is left behind
|
|
||||||
tmuxPane, zellijPane := takeEnv(tmuxBorderLabelEnv), takeEnv(zellijBorderLabelEnv)
|
|
||||||
var set func(string)
|
|
||||||
switch {
|
|
||||||
case tmuxPane != "":
|
|
||||||
set = func(label string) { setTmuxBorderLabel(tmuxPane, label) }
|
|
||||||
case zellijPane != "":
|
|
||||||
set = func(label string) { setZellijBorderLabel(zellijPane, label) }
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
updater := &labelUpdater{set: set}
|
|
||||||
return updater.update
|
|
||||||
}
|
|
||||||
|
|
||||||
func popupArgStr(args []string, opts *Options) (string, string) {
|
func popupArgStr(args []string, opts *Options) (string, string) {
|
||||||
fzf, rest := args[0], args[1:]
|
fzf, rest := args[0], args[1:]
|
||||||
args = []string{"--bind=ctrl-z:ignore"}
|
args = []string{"--bind=ctrl-z:ignore"}
|
||||||
@@ -191,9 +125,6 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
|
|||||||
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||||
for _, pairStr := range os.Environ() {
|
for _, pairStr := range os.Environ() {
|
||||||
pair := strings.SplitN(pairStr, "=", 2)
|
pair := strings.SplitN(pairStr, "=", 2)
|
||||||
if strings.HasPrefix(pair[0], internalEnvPrefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if validIdentifier.MatchString(pair[0]) {
|
if validIdentifier.MatchString(pair[0]) {
|
||||||
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
|
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
|
||||||
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
|
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package fzf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLabelUpdater(t *testing.T) {
|
|
||||||
var mu sync.Mutex
|
|
||||||
applied := []int{}
|
|
||||||
// The goroutine can still be running after the test function returns, so
|
|
||||||
// it records the label instead of reporting it, and -1 stands in for one
|
|
||||||
// that could not be parsed
|
|
||||||
updater := &labelUpdater{set: func(label string) {
|
|
||||||
n, err := strconv.Atoi(label)
|
|
||||||
if err != nil {
|
|
||||||
n = -1
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
applied = append(applied, n)
|
|
||||||
mu.Unlock()
|
|
||||||
// Long enough for the burst to pile up behind the first update
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}}
|
|
||||||
|
|
||||||
const last = 99
|
|
||||||
for i := 0; i <= last; i++ {
|
|
||||||
updater.update(strconv.Itoa(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
// However many updates are dropped, the final one is always applied
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for {
|
|
||||||
mu.Lock()
|
|
||||||
done := len(applied) > 0 && applied[len(applied)-1] == last
|
|
||||||
mu.Unlock()
|
|
||||||
if done {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("last update was not applied: %v", applied)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
// Updates are never applied out of order, so the border cannot be left
|
|
||||||
// showing a label older than the last one requested
|
|
||||||
for i, n := range applied {
|
|
||||||
if i > 0 && n <= applied[i-1] {
|
|
||||||
t.Errorf("applied out of order: %v", applied)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A burst collapses instead of running one command per update. The
|
|
||||||
// updates are queued in a tight loop while each one takes a millisecond
|
|
||||||
// to apply, so all of them landing would mean no coalescing at all
|
|
||||||
if len(applied) >= last+1 {
|
|
||||||
t.Errorf("expected updates to be coalesced, applied all %d", len(applied))
|
|
||||||
}
|
|
||||||
t.Logf("applied %d of %d updates", len(applied), last+1)
|
|
||||||
}
|
|
||||||
@@ -369,21 +369,9 @@ func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
|
|||||||
src, dst := a, buf
|
src, dst := a, buf
|
||||||
scattered := 0
|
scattered := 0
|
||||||
|
|
||||||
// OR of all keys: a byte position that is zero here is zero in every key,
|
|
||||||
// so its pass is a no-op and can be skipped without a histogram scan.
|
|
||||||
// With the common two-criteria setup the low 32 bits are always zero.
|
|
||||||
var keyOR uint64
|
|
||||||
for i := range src {
|
|
||||||
keyOR |= sortKey(&src[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
for pass := range 8 {
|
for pass := range 8 {
|
||||||
shift := uint(pass) * 8
|
shift := uint(pass) * 8
|
||||||
|
|
||||||
if byte(keyOR>>shift) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var count [256]int
|
var count [256]int
|
||||||
for i := range src {
|
for i := range src {
|
||||||
count[byte(sortKey(&src[i])>>shift)]++
|
count[byte(sortKey(&src[i])>>shift)]++
|
||||||
|
|||||||
+10
-139
@@ -57,12 +57,8 @@ var offsetComponentRegex *regexp.Regexp
|
|||||||
var offsetTrimCharsRegex *regexp.Regexp
|
var offsetTrimCharsRegex *regexp.Regexp
|
||||||
var passThroughBeginRegex *regexp.Regexp
|
var passThroughBeginRegex *regexp.Regexp
|
||||||
var passThroughEndTmuxRegex *regexp.Regexp
|
var passThroughEndTmuxRegex *regexp.Regexp
|
||||||
var sixelBeginRegex *regexp.Regexp
|
|
||||||
var cursorBackRegex *regexp.Regexp
|
|
||||||
var ttyin *os.File
|
var ttyin *os.File
|
||||||
|
|
||||||
var inTmux = len(os.Getenv("TMUX")) > 0
|
|
||||||
|
|
||||||
const clearCode string = "\x1b[2J"
|
const clearCode string = "\x1b[2J"
|
||||||
|
|
||||||
// Number of maximum focus events to process synchronously
|
// Number of maximum focus events to process synchronously
|
||||||
@@ -96,10 +92,6 @@ func init() {
|
|||||||
*/
|
*/
|
||||||
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
|
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
|
||||||
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
|
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
|
||||||
sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`)
|
|
||||||
|
|
||||||
// CUB right before an IND, used to return to the column a row started on
|
|
||||||
cursorBackRegex = regexp.MustCompile(`\x1b\[([0-9]*)D$`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type jumpMode int
|
type jumpMode int
|
||||||
@@ -476,7 +468,6 @@ type Terminal struct {
|
|||||||
clickFooterLine int
|
clickFooterLine int
|
||||||
clickFooterColumn int
|
clickFooterColumn int
|
||||||
proxyScript string
|
proxyScript string
|
||||||
setNativeLabel func(string)
|
|
||||||
numLinesCache map[int32]numLinesCacheValue
|
numLinesCache map[int32]numLinesCacheValue
|
||||||
raw bool
|
raw bool
|
||||||
lastActivity time.Time
|
lastActivity time.Time
|
||||||
@@ -1149,7 +1140,6 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
|||||||
printer: opts.Printer,
|
printer: opts.Printer,
|
||||||
printsep: opts.PrintSep,
|
printsep: opts.PrintSep,
|
||||||
proxyScript: opts.ProxyScript,
|
proxyScript: opts.ProxyScript,
|
||||||
setNativeLabel: nativeLabelSetter(),
|
|
||||||
merger: em,
|
merger: em,
|
||||||
passMerger: em,
|
passMerger: em,
|
||||||
resultMerger: em,
|
resultMerger: em,
|
||||||
@@ -2280,14 +2270,6 @@ func (t *Terminal) displayWidthWithPrefix(str string, prefixWidth int) int {
|
|||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
// displayWidthWithoutEscapes is displayWidthWithPrefix for a string that may
|
|
||||||
// still carry pass-throughs and ANSI codes, neither of which take any column.
|
|
||||||
func (t *Terminal) displayWidthWithoutEscapes(str string, prefixWidth int) int {
|
|
||||||
_, text := extractPassThroughs(str)
|
|
||||||
stripped, _, _ := extractColor(text, nil, nil)
|
|
||||||
return t.displayWidthWithPrefix(stripped, prefixWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
minWidth = 4
|
minWidth = 4
|
||||||
minHeight = 3
|
minHeight = 3
|
||||||
@@ -4691,23 +4673,15 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
|
|||||||
height := t.pwindow.Height()
|
height := t.pwindow.Height()
|
||||||
body := t.previewer.lines
|
body := t.previewer.lines
|
||||||
headerLines := t.activePreviewOpts.headerLines
|
headerLines := t.activePreviewOpts.headerLines
|
||||||
lineNo := -t.previewer.offset + headerLines
|
|
||||||
// Scrollbar is sized from the body alone, split off or not
|
|
||||||
scrollLines := len(body)
|
|
||||||
// Do not enable preview header lines if it's value is too large
|
// Do not enable preview header lines if it's value is too large
|
||||||
if headerLines > 0 && headerLines < min(len(body), height) {
|
if headerLines > 0 && headerLines < min(len(body), height) {
|
||||||
scrollLines -= headerLines
|
|
||||||
header := t.previewer.lines[0:headerLines]
|
header := t.previewer.lines[0:headerLines]
|
||||||
// A separate header pass would resume the body inside an image, which
|
body = t.previewer.lines[headerLines:]
|
||||||
// takes up more rows than the line it arrives on
|
// Always redraw header
|
||||||
if !containsImage(header) {
|
t.renderPreviewText(height, header, 0, false)
|
||||||
// Always redraw header
|
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
|
||||||
t.renderPreviewText(height, header, 0, false)
|
|
||||||
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
|
|
||||||
body = t.previewer.lines[headerLines:]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
t.renderPreviewText(height, body, lineNo, unchanged)
|
t.renderPreviewText(height, body, -t.previewer.offset+headerLines, unchanged)
|
||||||
|
|
||||||
if !unchanged {
|
if !unchanged {
|
||||||
t.pwindow.FinishFill()
|
t.pwindow.FinishFill()
|
||||||
@@ -4718,7 +4692,7 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
effectiveHeight := height - headerLines
|
effectiveHeight := height - headerLines
|
||||||
barLength, barStart := getScrollbar(1, scrollLines, effectiveHeight, min(scrollLines-effectiveHeight, t.previewer.offset-headerLines))
|
barLength, barStart := getScrollbar(1, len(body), effectiveHeight, min(len(body)-effectiveHeight, t.previewer.offset-headerLines))
|
||||||
t.renderPreviewScrollbar(headerLines, barLength, barStart)
|
t.renderPreviewScrollbar(headerLines, barLength, barStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4782,66 +4756,6 @@ func findPassThrough(line string) []int {
|
|||||||
return []int{loc[0], loc[1] + pos + 2}
|
return []int{loc[0], loc[1] + pos + 2}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tmux takes a bare APC as a request to set the pane title, so a Kitty
|
|
||||||
// graphics command never reaches the terminal and clobbers the title on the
|
|
||||||
// way. 'kitten icat --clear' emits one unwrapped. Sixel is left alone.
|
|
||||||
// https://github.com/junegunn/fzf/issues/4870
|
|
||||||
func wrapPassThrough(passThrough string, tmux bool) string {
|
|
||||||
if !tmux || !strings.HasPrefix(passThrough, "\x1b_G") {
|
|
||||||
return passThrough
|
|
||||||
}
|
|
||||||
// Only the sequence is passed through, not the trailing CR
|
|
||||||
suffix := ""
|
|
||||||
if strings.HasSuffix(passThrough, "\r") {
|
|
||||||
passThrough, suffix = passThrough[:len(passThrough)-1], "\r"
|
|
||||||
}
|
|
||||||
return "\x1bPtmux;" + strings.ReplaceAll(passThrough, "\x1b", "\x1b\x1b") + "\x1b\\" + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether the sequence draws an image. Kitty commands that only transmit or
|
|
||||||
// delete do not
|
|
||||||
func isImagePassThrough(passThrough string) bool {
|
|
||||||
// Unwrap the tmux passthrough sequence, in which every ESC is doubled
|
|
||||||
if after, ok := strings.CutPrefix(passThrough, "\x1bPtmux;"); ok {
|
|
||||||
passThrough = strings.ReplaceAll(after, "\x1b\x1b", "\x1b")
|
|
||||||
}
|
|
||||||
if after, ok := strings.CutPrefix(passThrough, "\x1b_G"); ok {
|
|
||||||
// Control data ends at the payload delimiter or at the terminator
|
|
||||||
keys := after
|
|
||||||
if index := strings.IndexAny(keys, ";\x1b"); index >= 0 {
|
|
||||||
keys = keys[:index]
|
|
||||||
}
|
|
||||||
for _, key := range strings.Split(keys, ",") {
|
|
||||||
// Transmit and display, or put an image already transmitted
|
|
||||||
if key == "a=T" || key == "a=p" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if after, ok := strings.CutPrefix(passThrough, "\x1b]1337;"); ok {
|
|
||||||
return strings.HasPrefix(after, "File=") || strings.HasPrefix(after, "MultipartFile=")
|
|
||||||
}
|
|
||||||
return sixelBeginRegex.MatchString(passThrough)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether any line carries an image
|
|
||||||
func containsImage(lines []string) bool {
|
|
||||||
for _, line := range lines {
|
|
||||||
for {
|
|
||||||
loc := findPassThrough(line)
|
|
||||||
if loc == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if isImagePassThrough(line[loc[0]:loc[1]]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
line = line[loc[1]:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractPassThroughs(line string) ([]string, string) {
|
func extractPassThroughs(line string) ([]string, string) {
|
||||||
passThroughs := []string{}
|
passThroughs := []string{}
|
||||||
transformed := ""
|
transformed := ""
|
||||||
@@ -4861,38 +4775,6 @@ func extractPassThroughs(line string) ([]string, string) {
|
|||||||
return passThroughs, transformed
|
return passThroughs, transformed
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitOnIND breaks a preview line on IND (ESC D), which moves the cursor
|
|
||||||
// down one line, keeping the column. A program drawing at a column offset ends
|
|
||||||
// its rows with IND instead of a newline, because ONLCR would rewrite a newline
|
|
||||||
// as CR NL and snap the cursor to column 0. chafa does this for Kitty Unicode
|
|
||||||
// placeholders, and without the break the whole image collapses into a single
|
|
||||||
// line.
|
|
||||||
//
|
|
||||||
// The column is tracked and re-created with padding so that an indented image
|
|
||||||
// keeps its indent, and a CUB right before the IND is subtracted, which is how
|
|
||||||
// chafa returns to the column its rows start on.
|
|
||||||
func (t *Terminal) splitOnIND(line string) []string {
|
|
||||||
chunks := strings.Split(line, "\x1bD")
|
|
||||||
if len(chunks) == 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := make([]string, 0, len(chunks))
|
|
||||||
col := 0
|
|
||||||
for _, chunk := range chunks[:len(chunks)-1] {
|
|
||||||
lines = append(lines, strings.Repeat(" ", col)+chunk+"\n")
|
|
||||||
col += t.displayWidthWithoutEscapes(chunk, col)
|
|
||||||
if match := cursorBackRegex.FindStringSubmatch(chunk); match != nil {
|
|
||||||
back := 1
|
|
||||||
if len(match[1]) > 0 {
|
|
||||||
back, _ = strconv.Atoi(match[1])
|
|
||||||
}
|
|
||||||
col = max(0, col-back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(lines, strings.Repeat(" ", col)+chunks[len(chunks)-1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// followOffset computes the correct content-line offset for follow mode,
|
// followOffset computes the correct content-line offset for follow mode,
|
||||||
// accounting for line wrapping in the preview window.
|
// accounting for line wrapping in the preview window.
|
||||||
func (t *Terminal) followOffset() int {
|
func (t *Terminal) followOffset() int {
|
||||||
@@ -5114,7 +4996,7 @@ Loop:
|
|||||||
} else {
|
} else {
|
||||||
t.pwindow.Move(y, x)
|
t.pwindow.Move(y, x)
|
||||||
}
|
}
|
||||||
t.tui.PassThrough(wrapPassThrough(passThrough, inTmux))
|
t.tui.PassThrough(passThrough)
|
||||||
|
|
||||||
if requiredLines > 0 {
|
if requiredLines > 0 {
|
||||||
if y+requiredLines == height {
|
if y+requiredLines == height {
|
||||||
@@ -6305,7 +6187,6 @@ func (t *Terminal) Loop() error {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
signal.Stop(intChan)
|
|
||||||
return
|
return
|
||||||
case s := <-intChan:
|
case s := <-intChan:
|
||||||
// Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself
|
// Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself
|
||||||
@@ -6318,7 +6199,7 @@ func (t *Terminal) Loop() error {
|
|||||||
|
|
||||||
if !t.tui.ShouldEmitResizeEvent() {
|
if !t.tui.ShouldEmitResizeEvent() {
|
||||||
resizeChan := make(chan os.Signal, 1)
|
resizeChan := make(chan os.Signal, 1)
|
||||||
notifyOnResize(ctx, resizeChan) // Non-portable
|
notifyOnResize(resizeChan) // Non-portable
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -6465,11 +6346,7 @@ func (t *Terminal) Loop() error {
|
|||||||
version--
|
version--
|
||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
if split := t.splitOnIND(line); split != nil {
|
lines = append(lines, line)
|
||||||
lines = append(lines, split...)
|
|
||||||
} else {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
|
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
|
||||||
@@ -7376,10 +7253,6 @@ func (t *Terminal) Loop() error {
|
|||||||
if t.border != nil {
|
if t.border != nil {
|
||||||
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
|
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
|
||||||
req(reqRedrawBorderLabel)
|
req(reqRedrawBorderLabel)
|
||||||
} else if t.setNativeLabel != nil {
|
|
||||||
// fzf draws no border of its own; the label is on the
|
|
||||||
// native border of the floating pane
|
|
||||||
t.setNativeLabel(label)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
|
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
|
||||||
@@ -7417,9 +7290,7 @@ func (t *Terminal) Loop() error {
|
|||||||
case actReplaceQuery:
|
case actReplaceQuery:
|
||||||
current := t.currentItem()
|
current := t.currentItem()
|
||||||
if current != nil {
|
if current != nil {
|
||||||
// ToRunes aliases the item text in rune mode, and the
|
t.input = current.text.ToRunes()
|
||||||
// editing actions below append into t.input in place
|
|
||||||
t.input = append([]rune{}, current.text.ToRunes()...)
|
|
||||||
t.cx = len(t.input)
|
t.cx = len(t.input)
|
||||||
}
|
}
|
||||||
case actFatal:
|
case actFatal:
|
||||||
|
|||||||
@@ -549,89 +549,6 @@ func TestExtractPassthroughs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrapPassThrough(t *testing.T) {
|
|
||||||
for _, passThrough := range []string{
|
|
||||||
"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", // Already wrapped
|
|
||||||
"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", // Sixel
|
|
||||||
"\x1b]1337;File=inline=1:AAAA\a", // iTerm2
|
|
||||||
} {
|
|
||||||
if got := wrapPassThrough(passThrough, true); got != passThrough {
|
|
||||||
t.Errorf("should have been left alone: %q -> %q", passThrough, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kitty := "\x1b_Ga=d,d=A\x1b\\"
|
|
||||||
if got := wrapPassThrough(kitty, false); got != kitty {
|
|
||||||
t.Errorf("should have been left alone outside of tmux: %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC characters are doubled, and the trailing carriage return is kept
|
|
||||||
// outside of the wrapper
|
|
||||||
for _, test := range []struct{ input, want string }{
|
|
||||||
{kitty, "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\"},
|
|
||||||
{kitty + "\r", "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\\r"},
|
|
||||||
{"\x1b_Gm=1;\x1bAAA=\x1b\\", "\x1bPtmux;\x1b\x1b_Gm=1;\x1b\x1bAAA=\x1b\x1b\\\x1b\\"},
|
|
||||||
} {
|
|
||||||
if got := wrapPassThrough(test.input, true); got != test.want {
|
|
||||||
t.Errorf("expected %q, got %q", test.want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsImagePassThrough(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
given string
|
|
||||||
image bool
|
|
||||||
}{
|
|
||||||
// Kitty
|
|
||||||
{"\x1b_Ga=T,f=32,s=1258,v=1295,c=74,r=35,m=1\x1b\\", true},
|
|
||||||
{"\x1b_Ga=p,i=1\x1b\\", true},
|
|
||||||
// The action key is not always first, and a put carries no payload
|
|
||||||
{"\x1b_Gi=1,a=p\x1b\\", true},
|
|
||||||
{"\x1b_Ga=p\x1b\\", true},
|
|
||||||
{"\x1b_Ga=T,f=100\x1b\\\r", true},
|
|
||||||
{"\x1b_Gi=1,a=d\x1b\\", false},
|
|
||||||
{"\x1b_Ga=d,d=A\x1b\\", false}, // 'kitten icat --clear'
|
|
||||||
{"\x1b_Ga=q,i=1\x1b\\", false}, // query
|
|
||||||
{"\x1b_Gi=1,f=100\x1b\\", false}, // transmit only, the default action
|
|
||||||
{"\x1b_Gm=1;AAAA\x1b\\", false}, // continuation chunk
|
|
||||||
{"\x1b_Ga=f,i=1;AAAA\x1b\\", false}, // animation frame
|
|
||||||
{"\x1b_Ga=T,U=1,f=32;AAAA\x1b\\", true}, // unicode placeholder
|
|
||||||
// Wrapped in the tmux passthrough sequence
|
|
||||||
{"\x1bPtmux;\x1b\x1b_Ga=T,f=100\x1b\x1b\\\x1b\\", true},
|
|
||||||
{"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", false},
|
|
||||||
{"\x1bPtmux;\x1b\x1b_Gi=1,a=p\x1b\x1b\\\x1b\\", true},
|
|
||||||
// iTerm2
|
|
||||||
{"\x1b]1337;File=inline=1:AAAA\a", true},
|
|
||||||
{"\x1b]1337;MultipartFile=inline=1\a", true},
|
|
||||||
{"\x1b]1337;SetUserVar=foo=YmFy\a", false},
|
|
||||||
{"\x1b]1337;CurrentDir=/tmp\a", false},
|
|
||||||
// Sixel
|
|
||||||
{"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", true},
|
|
||||||
{"\x1bPq#0~~\x1b\\", true},
|
|
||||||
{"\x1bPtmux;\x1b\x1bP0;1;0q#0~~\x1b\x1b\\\x1b\\", true},
|
|
||||||
{"", false},
|
|
||||||
} {
|
|
||||||
if actual := isImagePassThrough(tc.given); actual != tc.image {
|
|
||||||
t.Errorf("expected %v for %q, got %v", tc.image, tc.given, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if containsImage([]string{"foo", "bar"}) {
|
|
||||||
t.Error("plain text carries no image")
|
|
||||||
}
|
|
||||||
if !containsImage([]string{"foo", "bar\x1b_Ga=T,f=100\x1b\\baz"}) {
|
|
||||||
t.Error("failed to find an image in a line")
|
|
||||||
}
|
|
||||||
if containsImage([]string{"foo\x1b_Ga=d,d=A\x1b\\"}) {
|
|
||||||
t.Error("a delete command is not an image")
|
|
||||||
}
|
|
||||||
// The image is not the first passthrough on the line
|
|
||||||
if !containsImage([]string{"\x1b_Ga=d,d=A\x1b\\text\x1b_Ga=T,f=100;AAAA\x1b\\"}) {
|
|
||||||
t.Error("failed to look past an earlier passthrough")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* utilities section */
|
/* utilities section */
|
||||||
|
|
||||||
// Item represents one line in fzf UI. Usually it is relative path to files and folders.
|
// Item represents one line in fzf UI. Usually it is relative path to files and folders.
|
||||||
@@ -851,73 +768,3 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
|||||||
t.Errorf("Tab wrap: %q", result)
|
t.Errorf("Tab wrap: %q", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitOnIND(t *testing.T) {
|
|
||||||
term := &Terminal{tabstop: 8}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
line string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
// Nothing to do, so the caller keeps the line as it read it
|
|
||||||
name: "no IND",
|
|
||||||
line: "foo\n",
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// chafa: CUB returns to the column the row started on
|
|
||||||
name: "rows at column 0",
|
|
||||||
line: "AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
|
|
||||||
want: []string{"AAA\x1b[3D\n", "BBB\x1b[3D\n", "CCC\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 'printf " "; chafa ...'
|
|
||||||
name: "indented rows",
|
|
||||||
line: " AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
|
|
||||||
want: []string{" AAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tab indent expands to the tab stop",
|
|
||||||
line: "\tAAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
|
|
||||||
want: []string{"\tAAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// IND on its own keeps the column
|
|
||||||
name: "bare IND",
|
|
||||||
line: "AB\x1bDCD\n",
|
|
||||||
want: []string{"AB\n", " CD\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "CUB without a parameter moves back one",
|
|
||||||
line: "AB\x1b[D\x1bDCD\n",
|
|
||||||
want: []string{"AB\x1b[D\n", " CD\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "CUB past the left edge is clamped",
|
|
||||||
line: "AB\x1b[9D\x1bDCD\n",
|
|
||||||
want: []string{"AB\x1b[9D\n", "CD\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SGR codes take no column",
|
|
||||||
line: "\x1b[31mAB\x1b[m\x1b[2D\x1bDCD\n",
|
|
||||||
want: []string{"\x1b[31mAB\x1b[m\x1b[2D\n", "CD\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pass-throughs take no column",
|
|
||||||
line: "\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\x1bDCD\n",
|
|
||||||
want: []string{"\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\n", "CD\n"},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
got := term.splitOnIND(tc.line)
|
|
||||||
if len(got) != len(tc.want) {
|
|
||||||
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for idx, line := range got {
|
|
||||||
if line != tc.want[idx] {
|
|
||||||
t.Errorf("%s: line %d: got %q, want %q", tc.name, idx, line, tc.want[idx])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -11,12 +10,8 @@ import (
|
|||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
|
func notifyOnResize(resizeChan chan<- os.Signal) {
|
||||||
signal.Notify(resizeChan, syscall.SIGWINCH)
|
signal.Notify(resizeChan, syscall.SIGWINCH)
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
signal.Stop(resizeChan)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyStop(p *os.Process) {
|
func notifyStop(p *os.Process) {
|
||||||
|
|||||||
+2
-49
@@ -3,58 +3,11 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const resizePollInterval = 100 * time.Millisecond
|
func notifyOnResize(resizeChan chan<- os.Signal) {
|
||||||
|
// TODO
|
||||||
type resizeSignal struct{}
|
|
||||||
|
|
||||||
func (resizeSignal) String() string { return "resize" }
|
|
||||||
func (resizeSignal) Signal() {}
|
|
||||||
|
|
||||||
// Windows has no SIGWINCH, so poll the console screen buffer for window
|
|
||||||
// size changes instead.
|
|
||||||
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
|
|
||||||
consoleOut, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var info windows.ConsoleScreenBufferInfo
|
|
||||||
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
|
|
||||||
syscall.Close(consoleOut)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
last := info.Window
|
|
||||||
go func() {
|
|
||||||
defer syscall.Close(consoleOut)
|
|
||||||
ticker := time.NewTicker(resizePollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
current := info.Window
|
|
||||||
if current.Right-current.Left != last.Right-last.Left ||
|
|
||||||
current.Bottom-current.Top != last.Bottom-last.Top {
|
|
||||||
last = current
|
|
||||||
select {
|
|
||||||
case resizeChan <- resizeSignal{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyStop(p *os.Process) {
|
func notifyStop(p *os.Process) {
|
||||||
|
|||||||
+37
-48
@@ -46,35 +46,21 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
|||||||
return width, height, true
|
return width, height, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// tmux ends a command at an argument ending in ';', so a trailing one is
|
// A lone ';' argument is a command separator to tmux, aborting the whole
|
||||||
// escaped. Elsewhere it is not special
|
// command at parse time
|
||||||
func escapeTmuxSeparator(str string) string {
|
func escapeTmuxSeparator(str string) string {
|
||||||
if strings.HasSuffix(str, ";") {
|
if str == ";" {
|
||||||
return str[:len(str)-1] + `\;`
|
return `\;`
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
// '#[...]' is still processed when a substituted value is drawn, so '#' is
|
// Escape a string for use as the pane title; select-pane -T expands format
|
||||||
// doubled. '#{...}' is already literal
|
// expressions denoted by '#', but not time conversion specifiers
|
||||||
func escapeTmuxFormat(str string) string {
|
func escapeTmuxTitle(str string) string {
|
||||||
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
|
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The option pane-border-format reads back, and the pane it is set on
|
|
||||||
const (
|
|
||||||
tmuxBorderLabelOption = "@fzf-border-label"
|
|
||||||
tmuxBorderLabelEnv = internalEnvPrefix + "TMUX_LABEL_PANE"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Errors are ignored. The pane may be gone
|
|
||||||
func setTmuxBorderLabel(pane string, label string) {
|
|
||||||
// Strip ANSI sequences. tmux would draw the escape's remainder as text
|
|
||||||
label, _, _ = extractColor(label, nil, nil)
|
|
||||||
exec.Command("tmux", "set-option", "-p", "-t", pane,
|
|
||||||
tmuxBorderLabelOption, escapeTmuxFormat(label)).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert sizeSpec to the number of cells, clamped between the minimum
|
// Convert sizeSpec to the number of cells, clamped between the minimum
|
||||||
// footprint of 3, including the border, and the window size
|
// footprint of 3, including the border, and the window size
|
||||||
func tmuxDim(spec sizeSpec, window int) int {
|
func tmuxDim(spec sizeSpec, window int) int {
|
||||||
@@ -142,28 +128,35 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
|
|||||||
// remain-on-exit is on.
|
// remain-on-exit is on.
|
||||||
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
|
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
|
||||||
|
|
||||||
// --border-label lives in a pane-scoped user option that
|
// Set --border-label as the title of the floating pane, and as its
|
||||||
// pane-border-format reads back, so a program in the pane cannot
|
// pane-border-format so that it is displayed on the border when
|
||||||
// replace it by setting the title. Set even when empty, so
|
// pane-border-status is enabled. Without a label, the border text
|
||||||
// change-border-label can fill it in and the default pane status is
|
// is cleared so that the default pane status content (e.g. the
|
||||||
// not shown. pane-border-status is a window option until the next
|
// pane title) is not shown. pane-border-format is pane-scoped,
|
||||||
// tmux release, so it is left alone.
|
// but pane-border-status is a window option that only becomes
|
||||||
|
// pane-scoped in the next release of tmux, so it is left alone.
|
||||||
// https://github.com/tmux/tmux/commit/7a18fa281db3
|
// https://github.com/tmux/tmux/commit/7a18fa281db3
|
||||||
// --border-label-pos is ignored. With an explicit --border fzf draws
|
// --border-label-pos is ignored.
|
||||||
// the label itself, but the native border cannot be removed.
|
// The label is left to fzf when it draws its own border with the
|
||||||
ownsLabel := noBorderSpecified(opts)
|
// label on it. '--border=none' is not the case; fzf would not
|
||||||
label := ""
|
// display the label, but the native border of a floating pane
|
||||||
if ownsLabel {
|
// cannot be removed, so display the label on it nonetheless.
|
||||||
|
format := ""
|
||||||
|
if opts.BorderLabel.label != "" &&
|
||||||
|
(opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
|
||||||
|
opts.BorderShape == tui.BorderNone) {
|
||||||
// Strip ANSI sequences fzf would otherwise render itself
|
// Strip ANSI sequences fzf would otherwise render itself
|
||||||
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
|
label, _, _ := extractColor(opts.BorderLabel.label, nil, nil)
|
||||||
|
if label != "" {
|
||||||
|
setup += fmt.Sprintf(`tmux select-pane -t "$TMUX_PANE" -T %s 2> /dev/null; `,
|
||||||
|
escapeSingleQuote(escapeTmuxTitle(label)))
|
||||||
|
// The title is displayed verbatim; substituted values are
|
||||||
|
// not expanded again
|
||||||
|
format = "#{pane_title}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" %s %s 2> /dev/null; `,
|
|
||||||
tmuxBorderLabelOption, escapeSingleQuote(escapeTmuxFormat(label)))
|
|
||||||
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
|
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
|
||||||
escapeSingleQuote("#{"+tmuxBorderLabelOption+"}"))
|
escapeSingleQuote(format))
|
||||||
if ownsLabel {
|
|
||||||
setup += fmt.Sprintf(`export %s="$TMUX_PANE"; `, tmuxBorderLabelEnv)
|
|
||||||
}
|
|
||||||
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
|
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
|
||||||
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
|
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
|
||||||
// Unzoom the window first; creating a floating pane over a zoomed
|
// Unzoom the window first; creating a floating pane over a zoomed
|
||||||
@@ -200,20 +193,16 @@ exit "$code"`, newPane, code, signal, signal, code, code, code)
|
|||||||
}, opts, true)
|
}, opts, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether no box border was asked for. 'none' and 'line' count as none.
|
|
||||||
// fzf draws no box for either
|
|
||||||
func noBorderSpecified(opts *Options) bool {
|
|
||||||
return opts.BorderShape == tui.BorderUndefined ||
|
|
||||||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether to use the multiplexer's native border for the floating pane. Its
|
// Whether to use the multiplexer's native border for the floating pane. Its
|
||||||
// native border is the handle that makes the pane movable and resizable with
|
// native border is the handle that makes the pane movable and resizable with
|
||||||
// the mouse, so it is the default; 'border-native' forces it. It is not used
|
// the mouse, so it is the default; 'border-native' forces it. It is not used
|
||||||
// when a border style is explicitly specified with --border, so that the
|
// when a border style is explicitly specified with --border, so that the
|
||||||
// fzf-drawn border is the only one shown.
|
// fzf-drawn border is the only one shown. 'none' and 'line' are treated as no
|
||||||
|
// border; fzf draws no box for either, and 'line' only makes sense with
|
||||||
|
// --height.
|
||||||
func nativeBorder(opts *Options) bool {
|
func nativeBorder(opts *Options) bool {
|
||||||
return opts.Tmux.border || noBorderSpecified(opts)
|
return opts.Tmux.border || opts.BorderShape == tui.BorderUndefined ||
|
||||||
|
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTmux(args []string, opts *Options) (int, error) {
|
func runTmux(args []string, opts *Options) (int, error) {
|
||||||
|
|||||||
+11
-25
@@ -2,51 +2,37 @@ package fzf
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestEscapeTmuxFormat(t *testing.T) {
|
func TestEscapeTmuxTitle(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
given string
|
given string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{"", ""},
|
{"", ""},
|
||||||
{" fzf ", " fzf "},
|
{" fzf ", " fzf "},
|
||||||
{"100%", "100%"},
|
{"#", "##"},
|
||||||
// '#[' would be taken as a style directive when the label is drawn
|
{"##", "####"},
|
||||||
{"#[fg=red]x", "##[fg=red]x"},
|
|
||||||
// '#{' is already literal in a substituted value, but doubling it
|
|
||||||
// keeps a label that fzf renders itself looking the same
|
|
||||||
{"#{pane_id}", "##{pane_id}"},
|
|
||||||
{" C# notes #S ", " C## notes ##S "},
|
{" C# notes #S ", " C## notes ##S "},
|
||||||
|
{"100%", "100%"},
|
||||||
{";", `\;`},
|
{";", `\;`},
|
||||||
{"; rm", "; rm"},
|
{"; rm", "; rm"},
|
||||||
{"C#;", `C##\;`},
|
{" ; ", " ; "},
|
||||||
} {
|
} {
|
||||||
if actual := escapeTmuxFormat(tc.given); actual != tc.expected {
|
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
|
||||||
t.Errorf("expected %q, got %q", tc.expected, actual)
|
t.Errorf("expected %q, got %q", tc.expected, actual)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEscapeTmuxSeparator(t *testing.T) {
|
func TestEscapeTmuxTitleSeparator(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
given string
|
given string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{"", ""},
|
{"#;", "##;"},
|
||||||
{" fzf ", " fzf "},
|
{";#", ";##"},
|
||||||
{"#", "#"},
|
{";;", ";;"},
|
||||||
{"#{pane_id}", "#{pane_id}"},
|
|
||||||
{"100%", "100%"},
|
|
||||||
// tmux drops a trailing ';' and everything after it, so only that
|
|
||||||
// one is escaped
|
|
||||||
{";", `\;`},
|
|
||||||
{"abc;", `abc\;`},
|
|
||||||
{";;", `;\;`},
|
|
||||||
{`foo\;`, `foo\\;`},
|
|
||||||
{"; rm", "; rm"},
|
|
||||||
{" ; ", " ; "},
|
|
||||||
{"a;b", "a;b"},
|
|
||||||
} {
|
} {
|
||||||
if actual := escapeTmuxSeparator(tc.given); actual != tc.expected {
|
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
|
||||||
t.Errorf("expected %q, got %q", tc.expected, actual)
|
t.Errorf("expected %q, got %q", tc.expected, actual)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-142
@@ -3,7 +3,6 @@ package util
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/bits"
|
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -14,17 +13,9 @@ const (
|
|||||||
overflow32 uint32 = 0x80808080
|
overflow32 uint32 = 0x80808080
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
flagInBytes uint8 = 1 << iota
|
|
||||||
flagMayFold
|
|
||||||
)
|
|
||||||
|
|
||||||
type Chars struct {
|
type Chars struct {
|
||||||
slice []byte // or []rune
|
slice []byte // or []rune
|
||||||
// Only ever set, never cleared, so a reader racing a Prepend sees either
|
inBytes bool
|
||||||
// the old or the new value and both are safe. trimLength* is kept out
|
|
||||||
// because TrimLength rewrites it.
|
|
||||||
flags uint8
|
|
||||||
trimLengthKnown bool
|
trimLengthKnown bool
|
||||||
trimLength uint16
|
trimLength uint16
|
||||||
|
|
||||||
@@ -33,59 +24,6 @@ type Chars struct {
|
|||||||
Index int32
|
Index int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rune ranges that case folding or normalization can turn into ASCII, derived
|
|
||||||
// from algo's normalization table and unicode.ToLower, then merged. They are a
|
|
||||||
// superset of the exact set, which TestMayFoldToAsciiIsSuperset in the algo
|
|
||||||
// package pins. Grouped tightly on purpose: a wider merge would swallow Greek
|
|
||||||
// Extended, General Punctuation and the currency and letterlike blocks, and
|
|
||||||
// every line holding a curly quote or an em dash would then lose the
|
|
||||||
// prefilter. Cyrillic, Greek, Hebrew, Arabic, Thai, Devanagari, CJK, Hangul,
|
|
||||||
// kana, emoji, punctuation and box drawing are all outside.
|
|
||||||
const (
|
|
||||||
foldLo = 0x00C0
|
|
||||||
foldHi = 0xFF61
|
|
||||||
)
|
|
||||||
|
|
||||||
var foldableRanges = [...][2]rune{
|
|
||||||
{0x00C0, 0x01B6}, // Latin-1 Supplement, Latin Extended-A and -B
|
|
||||||
{0x01CD, 0x02AE}, // rest of Latin Extended-B and IPA Extensions
|
|
||||||
{0x0363, 0x036F}, // combining Latin small letters
|
|
||||||
{0x1D00, 0x1D22}, // Phonetic Extensions, small capitals
|
|
||||||
{0x1D62, 0x1D65}, // subscript letters
|
|
||||||
{0x1E00, 0x1EF9}, // Latin Extended Additional
|
|
||||||
{0x2071, 0x2071}, // superscript i
|
|
||||||
{0x2095, 0x209C}, // subscript letters
|
|
||||||
{0x212A, 0x212B}, // KELVIN SIGN and ANGSTROM SIGN, which fold by case
|
|
||||||
{0x2183, 0x2184}, // reversed roman numeral one hundred
|
|
||||||
{0x2C62, 0x2C7F}, // Latin Extended-C
|
|
||||||
{0xA78D, 0xA78D}, // Latin Extended-D
|
|
||||||
{0xA7AA, 0xA7B2}, // more Latin Extended-D
|
|
||||||
{0xA7C5, 0xA7C5},
|
|
||||||
{0xFF01, 0xFF61}, // fullwidth ASCII forms, and halfwidth ideographic full stop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walking the ranges costs a serial chain of comparisons per rune, which is
|
|
||||||
// measurable at ingestion, so precompute a bitmap instead.
|
|
||||||
var foldableBits = func() (bits [(foldHi-foldLo)/8 + 1]byte) {
|
|
||||||
for _, r := range foldableRanges {
|
|
||||||
for c := r[0]; c <= r[1]; c++ {
|
|
||||||
i := c - foldLo
|
|
||||||
bits[i>>3] |= 1 << (i & 7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MayFoldToAscii reports whether case folding or normalization could turn r
|
|
||||||
// into an ASCII character.
|
|
||||||
func MayFoldToAscii(r rune) bool {
|
|
||||||
i := uint32(r - foldLo)
|
|
||||||
if i > foldHi-foldLo {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return foldableBits[i>>3]&(1<<(i&7)) != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAscii(bytes []byte) (bool, int) {
|
func checkAscii(bytes []byte) (bool, int) {
|
||||||
i := 0
|
i := 0
|
||||||
for ; i <= len(bytes)-8; i += 8 {
|
for ; i <= len(bytes)-8; i += 8 {
|
||||||
@@ -106,94 +44,31 @@ func checkAscii(bytes []byte) (bool, int) {
|
|||||||
return true, 0
|
return true, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRunes counts the bytes that are not UTF-8 continuation bytes, which is
|
|
||||||
// the rune count of valid UTF-8. Each invalid byte decodes to its own
|
|
||||||
// RuneError, so the result can undercount but never overcount, making it safe
|
|
||||||
// as a capacity hint.
|
|
||||||
func countRunes(bytes []byte) int {
|
|
||||||
n, i := 0, 0
|
|
||||||
for ; i <= len(bytes)-8; i += 8 {
|
|
||||||
v := *(*uint64)(unsafe.Pointer(&bytes[i]))
|
|
||||||
// Continuation byte: bit 7 set, bit 6 clear. In `v << 1` bit 7 of each
|
|
||||||
// lane holds bit 6 of that same lane.
|
|
||||||
n += 8 - bits.OnesCount64(v&^(v<<1)&overflow64)
|
|
||||||
}
|
|
||||||
for ; i < len(bytes); i++ {
|
|
||||||
if bytes[i]&0xC0 != 0x80 {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToChars converts byte array into rune array
|
// ToChars converts byte array into rune array
|
||||||
func ToChars(bytes []byte) Chars {
|
func ToChars(bytes []byte) Chars {
|
||||||
inBytes, bytesUntil := checkAscii(bytes)
|
inBytes, bytesUntil := checkAscii(bytes)
|
||||||
if inBytes {
|
if inBytes {
|
||||||
return Chars{slice: bytes, flags: flagInBytes}
|
return Chars{slice: bytes, inBytes: inBytes}
|
||||||
}
|
}
|
||||||
|
|
||||||
runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:]))
|
runes := make([]rune, bytesUntil, len(bytes))
|
||||||
for i := range bytesUntil {
|
for i := range bytesUntil {
|
||||||
runes[i] = rune(bytes[i])
|
runes[i] = rune(bytes[i])
|
||||||
}
|
}
|
||||||
mayFold := false
|
|
||||||
for i := bytesUntil; i < len(bytes); {
|
for i := bytesUntil; i < len(bytes); {
|
||||||
// utf8.DecodeRune has an ASCII path of its own, but it is too complex
|
|
||||||
// to inline, so a mostly-ASCII line pays one call per byte for it.
|
|
||||||
// An ASCII rune never sets the fold bit either, so skip both calls.
|
|
||||||
if b := bytes[i]; b < utf8.RuneSelf {
|
|
||||||
runes = append(runes, rune(b))
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r, sz := utf8.DecodeRune(bytes[i:])
|
r, sz := utf8.DecodeRune(bytes[i:])
|
||||||
i += sz
|
i += sz
|
||||||
mayFold = mayFold || MayFoldToAscii(r)
|
|
||||||
runes = append(runes, r)
|
runes = append(runes, r)
|
||||||
}
|
}
|
||||||
return runesToChars(runes, mayFold)
|
return RunesToChars(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunesToChars adopts the caller's slice rather than copying it, so the caller
|
|
||||||
// must not keep mutating it. See Runes for why.
|
|
||||||
func RunesToChars(runes []rune) Chars {
|
func RunesToChars(runes []rune) Chars {
|
||||||
mayFold := false
|
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false}
|
||||||
for _, r := range runes {
|
|
||||||
if MayFoldToAscii(r) {
|
|
||||||
mayFold = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runesToChars(runes, mayFold)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runesToChars(runes []rune, mayFold bool) Chars {
|
|
||||||
var flags uint8
|
|
||||||
if mayFold {
|
|
||||||
flags = flagMayFold
|
|
||||||
}
|
|
||||||
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), flags: flags}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) IsBytes() bool {
|
func (chars *Chars) IsBytes() bool {
|
||||||
return chars.flags&flagInBytes != 0
|
return chars.inBytes
|
||||||
}
|
|
||||||
|
|
||||||
// MayFoldToAscii reports whether the text holds a rune that case folding or
|
|
||||||
// normalization could turn into an ASCII character. When false, an ASCII
|
|
||||||
// pattern character can only match the identical ASCII rune, which is what
|
|
||||||
// lets the prefilter scan the rune array directly.
|
|
||||||
func (chars *Chars) MayFoldToAscii() bool {
|
|
||||||
return chars.flags&flagMayFold != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runes returns the underlying rune slice, or nil if the text is kept as
|
|
||||||
// bytes. Read only. The result aliases the text, so writing to it would change
|
|
||||||
// the text without updating the cached fold bit, and the prefilter would then
|
|
||||||
// reject items it should match. Copy before mutating.
|
|
||||||
func (chars *Chars) Runes() []rune {
|
|
||||||
return chars.optionalRunes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) Bytes() []byte {
|
func (chars *Chars) Bytes() []byte {
|
||||||
@@ -230,7 +105,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) optionalRunes() []rune {
|
func (chars *Chars) optionalRunes() []rune {
|
||||||
if chars.IsBytes() {
|
if chars.inBytes {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return *(*[]rune)(unsafe.Pointer(&chars.slice))
|
return *(*[]rune)(unsafe.Pointer(&chars.slice))
|
||||||
@@ -252,7 +127,7 @@ func (chars *Chars) Length() int {
|
|||||||
|
|
||||||
// String returns the string representation of a Chars object.
|
// String returns the string representation of a Chars object.
|
||||||
func (chars *Chars) String() string {
|
func (chars *Chars) String() string {
|
||||||
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, mayFold: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.IsBytes(), chars.MayFoldToAscii(), chars.trimLengthKnown, chars.trimLength, chars.Index)
|
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.inBytes, chars.trimLengthKnown, chars.trimLength, chars.Index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrimLength returns the length after trimming leading and trailing whitespaces
|
// TrimLength returns the length after trimming leading and trailing whitespaces
|
||||||
@@ -343,8 +218,6 @@ func (chars *Chars) ToString() string {
|
|||||||
return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice))
|
return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToRunes returns the text as runes. In rune mode the result aliases the text
|
|
||||||
// and must not be mutated, see Runes. In byte mode it is a fresh slice.
|
|
||||||
func (chars *Chars) ToRunes() []rune {
|
func (chars *Chars) ToRunes() []rune {
|
||||||
if runes := chars.optionalRunes(); runes != nil {
|
if runes := chars.optionalRunes(); runes != nil {
|
||||||
return runes
|
return runes
|
||||||
@@ -374,12 +247,6 @@ func (chars *Chars) Prepend(prefix string) {
|
|||||||
} else {
|
} else {
|
||||||
chars.slice = append([]byte(prefix), chars.slice...)
|
chars.slice = append([]byte(prefix), chars.slice...)
|
||||||
}
|
}
|
||||||
for _, r := range prefix {
|
|
||||||
if MayFoldToAscii(r) {
|
|
||||||
chars.flags |= flagMayFold
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
|
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
|
||||||
|
|||||||
+2
-156
@@ -2,106 +2,19 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
|
||||||
"unsafe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCountRunes(t *testing.T) {
|
|
||||||
for _, str := range []string{
|
|
||||||
"", "a", "abc", "한글", "🎉🎉", "\tabc한글 ",
|
|
||||||
strings.Repeat("漢字", 50), strings.Repeat("a", 33) + "é",
|
|
||||||
} {
|
|
||||||
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
|
|
||||||
t.Errorf("countRunes(%q) = %d, expected %d", str, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountRunesRandom(t *testing.T) {
|
|
||||||
rng := rand.New(rand.NewSource(1))
|
|
||||||
|
|
||||||
// Exact on valid UTF-8
|
|
||||||
for trial := range 20000 {
|
|
||||||
var sb strings.Builder
|
|
||||||
for range rng.Intn(20) {
|
|
||||||
r := rune(rng.Intn(utf8.MaxRune + 1))
|
|
||||||
for r >= 0xD800 && r <= 0xDFFF {
|
|
||||||
r = rune(rng.Intn(utf8.MaxRune + 1))
|
|
||||||
}
|
|
||||||
sb.WriteRune(r)
|
|
||||||
}
|
|
||||||
str := sb.String()
|
|
||||||
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
|
|
||||||
t.Fatalf("trial %d: countRunes(%q) = %d, expected %d", trial, str, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never an overcount on arbitrary bytes, so the capacity hint never truncates
|
|
||||||
for trial := range 20000 {
|
|
||||||
buf := make([]byte, rng.Intn(40))
|
|
||||||
rng.Read(buf)
|
|
||||||
if got, exp := countRunes(buf), utf8.RuneCount(buf); got > exp {
|
|
||||||
t.Fatalf("trial %d: countRunes(%x) = %d, overcounts %d", trial, buf, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToChars must produce exactly what a []rune conversion produces, including
|
|
||||||
// one RuneError per invalid byte, and must size the rune slice exactly when
|
|
||||||
// the input is valid UTF-8.
|
|
||||||
func TestToCharsIntegrity(t *testing.T) {
|
|
||||||
rng := rand.New(rand.NewSource(2))
|
|
||||||
check := func(buf []byte, exactCap bool) {
|
|
||||||
chars := ToChars(buf)
|
|
||||||
exp := []rune(string(buf))
|
|
||||||
if chars.Length() != len(exp) {
|
|
||||||
t.Fatalf("ToChars(%x).Length() = %d, expected %d", buf, chars.Length(), len(exp))
|
|
||||||
}
|
|
||||||
for i, r := range exp {
|
|
||||||
if chars.Get(i) != r {
|
|
||||||
t.Fatalf("ToChars(%x).Get(%d) = %q, expected %q", buf, i, chars.Get(i), r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runes := chars.optionalRunes(); runes != nil && exactCap && cap(runes) != len(exp) {
|
|
||||||
t.Fatalf("ToChars(%x) cap = %d, expected %d", buf, cap(runes), len(exp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for range 5000 {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("ascii")
|
|
||||||
for range 1 + rng.Intn(10) {
|
|
||||||
r := rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
|
|
||||||
for r >= 0xD800 && r <= 0xDFFF {
|
|
||||||
r = rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
|
|
||||||
}
|
|
||||||
sb.WriteRune(r)
|
|
||||||
}
|
|
||||||
check([]byte(sb.String()), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid UTF-8: still correct, capacity may grow
|
|
||||||
for range 5000 {
|
|
||||||
buf := make([]byte, 1+rng.Intn(40))
|
|
||||||
rng.Read(buf)
|
|
||||||
buf[rng.Intn(len(buf))] |= 0x80 // force the rune path
|
|
||||||
check(buf, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCharsAscii(t *testing.T) {
|
func TestToCharsAscii(t *testing.T) {
|
||||||
chars := ToChars([]byte("foobar"))
|
chars := ToChars([]byte("foobar"))
|
||||||
if !chars.IsBytes() || chars.ToString() != "foobar" {
|
if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes {
|
||||||
t.Error()
|
t.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCharsLength(t *testing.T) {
|
func TestCharsLength(t *testing.T) {
|
||||||
chars := ToChars([]byte("\tabc한글 "))
|
chars := ToChars([]byte("\tabc한글 "))
|
||||||
if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 {
|
if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 {
|
||||||
t.Error()
|
t.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,70 +129,3 @@ func TestCharsLinesWrapWord(t *testing.T) {
|
|||||||
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
|
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chars is one per input line, so its size is load-bearing. It has no spare
|
|
||||||
// padding, which is why new state goes in the flags byte rather than a field.
|
|
||||||
// Derive the expectation from the slice header so the invariant holds on
|
|
||||||
// 32-bit builds too, where the header is 12 bytes and Chars is 20.
|
|
||||||
func TestCharsSize(t *testing.T) {
|
|
||||||
var slice []byte
|
|
||||||
// flags 1 + trimLengthKnown 1 + trimLength 2 + Index 4, no padding
|
|
||||||
want := unsafe.Sizeof(slice) + 8
|
|
||||||
if size := unsafe.Sizeof(Chars{}); size != want {
|
|
||||||
t.Errorf("unsafe.Sizeof(Chars{}) = %d, expected %d", size, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMayFoldFlag(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
text string
|
|
||||||
fold bool
|
|
||||||
}{
|
|
||||||
{"한글/src", false}, {"漢字", false}, {"мир", false}, {"🎉", false},
|
|
||||||
{"café", true}, {"Müller", true}, {"Å", true}, {"full", true},
|
|
||||||
} {
|
|
||||||
chars := ToChars([]byte(c.text))
|
|
||||||
if chars.MayFoldToAscii() != c.fold {
|
|
||||||
t.Errorf("ToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, chars.MayFoldToAscii(), c.fold)
|
|
||||||
}
|
|
||||||
if runes := RunesToChars([]rune(c.text)); runes.MayFoldToAscii() != c.fold {
|
|
||||||
t.Errorf("RunesToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, runes.MayFoldToAscii(), c.fold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepend can introduce foldable runes
|
|
||||||
chars := ToChars([]byte("한글"))
|
|
||||||
if chars.MayFoldToAscii() {
|
|
||||||
t.Fatal("baseline should not be foldable")
|
|
||||||
}
|
|
||||||
chars.Prepend("é")
|
|
||||||
if !chars.MayFoldToAscii() {
|
|
||||||
t.Error("Prepend of a foldable prefix must set the flag")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runes and ToRunes alias the text in rune mode, so a consumer that mutates
|
|
||||||
// what they return changes the text without updating the cached fold bit. This
|
|
||||||
// pins the aliasing so the read-only contract on those methods is not silently
|
|
||||||
// dropped later.
|
|
||||||
func TestRuneSlicesAliasTheText(t *testing.T) {
|
|
||||||
chars := ToChars([]byte("한글abc"))
|
|
||||||
runes := chars.Runes()
|
|
||||||
if runes == nil {
|
|
||||||
t.Fatal("expected rune mode")
|
|
||||||
}
|
|
||||||
if &runes[0] != &chars.ToRunes()[0] {
|
|
||||||
t.Error("Runes and ToRunes should return the same backing array")
|
|
||||||
}
|
|
||||||
if chars.MayFoldToAscii() {
|
|
||||||
t.Fatal("baseline should not be foldable")
|
|
||||||
}
|
|
||||||
// Demonstrates why callers must copy: the flag does not follow the text.
|
|
||||||
runes[0] = 'e'
|
|
||||||
if chars.MayFoldToAscii() {
|
|
||||||
t.Error("flag unexpectedly updated")
|
|
||||||
}
|
|
||||||
if got := chars.ToString(); got != "e글abc" {
|
|
||||||
t.Errorf("expected the write to reach the text, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-22
@@ -1,20 +1,11 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/junegunn/fzf/src/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const zellijBorderLabelEnv = internalEnvPrefix + "ZELLIJ_LABEL_PANE"
|
|
||||||
|
|
||||||
// Errors are ignored. The pane may be gone
|
|
||||||
func setZellijBorderLabel(pane string, label string) {
|
|
||||||
// Strip ANSI sequences fzf would otherwise render itself
|
|
||||||
label, _, _ = extractColor(label, nil, nil)
|
|
||||||
// '--' so that a label starting with a hyphen is not parsed as a flag
|
|
||||||
exec.Command("zellij", "action", "rename-pane", "-p", pane, "--", label).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runZellij(args []string, opts *Options) (int, error) {
|
func runZellij(args []string, opts *Options) (int, error) {
|
||||||
// Use the native Zellij border by default, consistent with tmux, so that
|
// Use the native Zellij border by default, consistent with tmux, so that
|
||||||
// the pane can be moved and resized with the mouse. Set before
|
// the pane can be moved and resized with the mouse. Set before
|
||||||
@@ -29,7 +20,6 @@ func runZellij(args []string, opts *Options) (int, error) {
|
|||||||
"run", "--floating", "--close-on-exit", "--block-until-exit",
|
"run", "--floating", "--close-on-exit", "--block-until-exit",
|
||||||
"--cwd", dir,
|
"--cwd", dir,
|
||||||
}
|
}
|
||||||
ownsLabel := false
|
|
||||||
if !opts.Tmux.border {
|
if !opts.Tmux.border {
|
||||||
zellijArgs = append(zellijArgs, "--borderless", "true")
|
zellijArgs = append(zellijArgs, "--borderless", "true")
|
||||||
} else {
|
} else {
|
||||||
@@ -43,8 +33,8 @@ func runZellij(args []string, opts *Options) (int, error) {
|
|||||||
// stripping ANSI sequences fzf would otherwise render itself.
|
// stripping ANSI sequences fzf would otherwise render itself.
|
||||||
// --border-label-pos is ignored.
|
// --border-label-pos is ignored.
|
||||||
label := ""
|
label := ""
|
||||||
ownsLabel = noBorderSpecified(opts)
|
if opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
|
||||||
if ownsLabel {
|
opts.BorderShape == tui.BorderNone {
|
||||||
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
|
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
|
||||||
}
|
}
|
||||||
zellijArgs = append(zellijArgs, "--name="+label)
|
zellijArgs = append(zellijArgs, "--name="+label)
|
||||||
@@ -70,14 +60,7 @@ func runZellij(args []string, opts *Options) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if ownsLabel {
|
zellijArgs = append(zellijArgs, sh, temp)
|
||||||
// 'zellij run' cannot set the new pane's environment, so the
|
|
||||||
// command exports it itself. exec avoids a lingering shell
|
|
||||||
zellijArgs = append(zellijArgs, sh, "-c", fmt.Sprintf(`export %s="$ZELLIJ_PANE_ID"; exec %s %s`,
|
|
||||||
zellijBorderLabelEnv, escapeSingleQuote(sh), escapeSingleQuote(temp)))
|
|
||||||
} else {
|
|
||||||
zellijArgs = append(zellijArgs, sh, temp)
|
|
||||||
}
|
|
||||||
return exec.Command("zellij", zellijArgs...), nil
|
return exec.Command("zellij", zellijArgs...), nil
|
||||||
}, opts, true)
|
}, opts, true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,19 +479,6 @@ class TestCore < TestInteractive
|
|||||||
tmux.until { |lines| assert_equal '> 10', lines[-1] }
|
tmux.until { |lines| assert_equal '> 10', lines[-1] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_bind_replace_query_does_not_mutate_item
|
|
||||||
tmux.send_keys "echo '한글abcde' | #{fzf('--bind=ctrl-j:replace-query,ctrl-o:clear-query')}", :Enter
|
|
||||||
tmux.until { |lines| assert_equal ' 1/1', lines[-2] }
|
|
||||||
tmux.send_keys 'C-j'
|
|
||||||
tmux.until { |lines| assert_equal '> 한글abcde', lines[-1] }
|
|
||||||
# Editing away from the end used to write into the item itself
|
|
||||||
tmux.send_keys :Left, :BSpace
|
|
||||||
tmux.until { |lines| assert_equal '> 한글abce', lines[-1] }
|
|
||||||
tmux.send_keys 'C-o'
|
|
||||||
tmux.until { |lines| assert_equal '>', lines[-1] }
|
|
||||||
tmux.until { |lines| assert_equal '> 한글abcde', lines[-3] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_select_all_deselect_all_toggle_all
|
def test_select_all_deselect_all_toggle_all
|
||||||
tmux.send_keys "seq 100 | #{fzf('--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi')}", :Enter
|
tmux.send_keys "seq 100 | #{fzf('--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi')}", :Enter
|
||||||
tmux.until { |lines| assert_equal ' 100/100 (0)', lines[-2] }
|
tmux.until { |lines| assert_equal ' 100/100 (0)', lines[-2] }
|
||||||
|
|||||||
@@ -965,18 +965,6 @@ class TestZsh < TestBase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Duplicate 'chpwd' calls overcount visits => skews rank tracking tools (e.g. 'zoxide')
|
|
||||||
def test_alt_c_chpwd_hook_once
|
|
||||||
tmux.send_keys "chpwd() { echo 'chpwd hook fired' >&2 }", :Enter
|
|
||||||
tmux.prepare
|
|
||||||
tmux.send_keys :Escape, :c
|
|
||||||
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
|
|
||||||
tmux.send_keys :Enter
|
|
||||||
tmux.until do |lines|
|
|
||||||
assert_equal(1, lines.count { |l| l.include?('chpwd hook fired') })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper function to run test with Perl and again with Awk
|
# Helper function to run test with Perl and again with Awk
|
||||||
def self.test_perl_and_awk(name, &block)
|
def self.test_perl_and_awk(name, &block)
|
||||||
define_method(:"test_#{name}") do
|
define_method(:"test_#{name}") do
|
||||||
@@ -1011,20 +999,6 @@ class TestZsh < TestBase
|
|||||||
tmux.send_keys 'C-l', 'C-r'
|
tmux.send_keys 'C-l', 'C-r'
|
||||||
end
|
end
|
||||||
|
|
||||||
test_perl_and_awk 'ctrl_r_exit_code' do
|
|
||||||
exit_file = "#{tempname}-exit"
|
|
||||||
# Wrapper captures status, as widgets return values don't propagate to "$?"
|
|
||||||
# A non-zero status causes the shell to beep when the widget exits (man zshzle)
|
|
||||||
tmux.send_keys %(f() { zle fzf-history-widget; echo $? > #{exit_file}; zle reset-prompt } && zle -N f && bindkey "^R" f), :Enter
|
|
||||||
prepare_ctrl_r_test
|
|
||||||
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
|
|
||||||
tmux.send_keys 'C-g' # abort
|
|
||||||
tmux.send_keys "cat #{exit_file}", :Enter
|
|
||||||
tmux.until { |lines| assert_equal '130', lines[-1] }
|
|
||||||
ensure
|
|
||||||
FileUtils.rm_f(exit_file)
|
|
||||||
end
|
|
||||||
|
|
||||||
test_perl_and_awk 'ctrl_r_accept_or_print_query' do
|
test_perl_and_awk 'ctrl_r_accept_or_print_query' do
|
||||||
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
|
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
|
||||||
prepare_ctrl_r_test
|
prepare_ctrl_r_test
|
||||||
|
|||||||
+6
-45
@@ -17,11 +17,9 @@ class TestTmux < TestInteractive
|
|||||||
def test_floating_pane
|
def test_floating_pane
|
||||||
tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter
|
tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter
|
||||||
tmux.until { |lines| assert_equal 100, lines.item_count }
|
tmux.until { |lines| assert_equal 100, lines.item_count }
|
||||||
# Border text is empty when no label is given
|
# Border text is cleared when no label is given
|
||||||
pane = floating_pane
|
format = IO.popen(['tmux', 'show-options', '-p', '-t', floating_pane, 'pane-border-format'], &:read)
|
||||||
refute_nil pane
|
assert_includes format, "''"
|
||||||
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
|
|
||||||
assert_equal '', pane_option(pane, '@fzf-border-label')
|
|
||||||
tmux.send_keys '99'
|
tmux.send_keys '99'
|
||||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||||
tmux.send_keys :Enter
|
tmux.send_keys :Enter
|
||||||
@@ -42,41 +40,10 @@ class TestTmux < TestInteractive
|
|||||||
tmux.until { |lines| assert_equal 100, lines.item_count }
|
tmux.until { |lines| assert_equal 100, lines.item_count }
|
||||||
pane = floating_pane
|
pane = floating_pane
|
||||||
refute_nil pane
|
refute_nil pane
|
||||||
# The label is held in a user option, not in the pane title, which is
|
|
||||||
# left to the user. '#' is doubled so that the label is drawn as it is
|
|
||||||
# written, instead of '#[...]' being taken as a style directive
|
|
||||||
assert_equal ' ##fzf-label 100% ', pane_option(pane, '@fzf-border-label')
|
|
||||||
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
|
|
||||||
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
|
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
|
||||||
refute_equal ' #fzf-label 100% ', title.chomp
|
assert_equal ' #fzf-label 100% ', title.chomp
|
||||||
tmux.send_keys :Enter
|
format = IO.popen(['tmux', 'show-options', '-p', '-t', pane, 'pane-border-format'], &:read)
|
||||||
assert_equal '1', fzf_output
|
assert_includes format, "\#{pane_title}"
|
||||||
end
|
|
||||||
|
|
||||||
def test_floating_pane_change_border_label
|
|
||||||
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'space:change-border-label( #[fg=red] 100% )'))}", :Enter
|
|
||||||
tmux.until { |lines| assert_equal 100, lines.item_count }
|
|
||||||
pane = floating_pane
|
|
||||||
refute_nil pane
|
|
||||||
assert_equal '', pane_option(pane, '@fzf-border-label')
|
|
||||||
tmux.send_keys :Space
|
|
||||||
wait { assert_equal ' ##[fg=red] 100% ', pane_option(pane, '@fzf-border-label') }
|
|
||||||
tmux.send_keys :Enter
|
|
||||||
assert_equal '1', fzf_output
|
|
||||||
end
|
|
||||||
|
|
||||||
# A program running in the pane owns the pane title, but not the label
|
|
||||||
def test_floating_pane_border_label_not_affected_by_title
|
|
||||||
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' label ' --bind 'space:execute-silent(printf "\\033]2;hijacked\\033\\\\" > /dev/tty)'))}", :Enter
|
|
||||||
tmux.until { |lines| assert_equal 100, lines.item_count }
|
|
||||||
pane = floating_pane
|
|
||||||
refute_nil pane
|
|
||||||
tmux.send_keys :Space
|
|
||||||
wait do
|
|
||||||
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
|
|
||||||
assert_equal 'hijacked', title.chomp
|
|
||||||
end
|
|
||||||
assert_equal ' label ', pane_option(pane, '@fzf-border-label')
|
|
||||||
tmux.send_keys :Enter
|
tmux.send_keys :Enter
|
||||||
assert_equal '1', fzf_output
|
assert_equal '1', fzf_output
|
||||||
end
|
end
|
||||||
@@ -112,12 +79,6 @@ class TestTmux < TestInteractive
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# stderr is merged in so that an unset option, which prints 'invalid
|
|
||||||
# option' to stderr and nothing to stdout, is not read as an empty value
|
|
||||||
def pane_option(pane, name)
|
|
||||||
IO.popen(['tmux', 'show-options', '-p', '-t', pane, '-v', name], err: %i[child out], &:read).chomp
|
|
||||||
end
|
|
||||||
|
|
||||||
def floating_pane
|
def floating_pane
|
||||||
format = "\#{pane_id} \#{pane_floating_flag}"
|
format = "\#{pane_id} \#{pane_floating_flag}"
|
||||||
lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) }
|
lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) }
|
||||||
|
|||||||
Reference in New Issue
Block a user