Compare commits

..
Author SHA1 Message Date
dependabot[bot] 066308b33b Bump github/codeql-action from 4 to 4.37.4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 13:43:49 +00:00
33 changed files with 156 additions and 2113 deletions
+3 -3
View File
@@ -33,12 +33,12 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.37.4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v4.37.4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.37.4
+1 -3
View File
@@ -38,9 +38,7 @@ jobs:
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
sudo apt-get update
# Pinned: 0.115.0 runs external completers on a background thread, so
# fzf never receives keystrokes. https://github.com/nushell/nushell/issues/18771
sudo apt-get install --yes zsh fish tmux shfmt nushell=0.114.1-0
sudo apt-get install --yes zsh fish tmux shfmt nushell
- name: Install Ruby gems
run: bundle install
-30
View File
@@ -1,36 +1,6 @@
CHANGELOG
=========
0.74.4
------
- Fixed an escape sequence split across reads being parsed as a fragment, which leaked the rest into the query (#4899)
- e.g. A terminal answering the startup `DECRQM` query late left `?2004;2$y`, CTRL-UP left `5A`, and SGR mouse input left `0;1;1M`
- Fixed `--tiebreak=pathname` not detecting the last path separator when the line contains a non-ASCII character before it (#4902)
- Vim plugin
- fzf no longer blocks the editor, so live previews keep working while fzf is open
- `fzf#run` returns an empty list when it runs fzf asynchronously. Use `sink`, `sinklist`, or `exit` to get the result
- The popup layout now works under Zellij
- Added `popup` as a synonym of the `tmux` layout key
```vim
let g:fzf_layout = { 'popup': '90%,70%' }
```
- fzf now opens in a tmux or Zellij floating pane by default, so the window it was started from stays visible and can be used while fzf is running
- Requires tmux 3.7+ or Zellij 0.44+
- Set `g:fzf_layout` to pick a different layout
0.74.3
------
- Performance optimizations for non-ASCII input
- ASCII queries are up to 16x faster
- Non-ASCII queries are up to 12x faster
- Reading accented Latin input is up to 37% faster
- Reading CJK input reduces memory use by up to 29%
- 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
- fzf no longer turns bracketed paste mode off on exit when the terminal already had it on, which broke pasting in shells that run fzf from a line editor widget (#4887)
- Fixed startup blocking on terminals that never answer escape sequences, such as FreeBSD virtual terminals. fzf waited for a reply until a key was pressed, then dropped that keystroke (#2860, #976)
0.74.2
------
- Performance optimizations for short queries
+1 -1
View File
@@ -102,7 +102,7 @@ itest:
# FUZZTIME (e.g. make fuzz FUZZTIME=5m).
FUZZTIME ?= 30s
fuzz:
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two; do \
echo "== $$t =="; \
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
done
+20 -76
View File
@@ -1,26 +1,6 @@
FZF Vim integration
===================
<!-- vim-markdown-toc GFM -->
* [Installation](#installation)
* [Summary](#summary)
* [`:FZF[!]`](#fzf)
* [Configuration](#configuration)
* [Examples](#examples)
* [Explanation of `g:fzf_colors`](#explanation-of-gfzf_colors)
* [`fzf#run`](#fzfrun)
* [`fzf#wrap`](#fzfwrap)
* [Global options supported by `fzf#wrap`](#global-options-supported-by-fzfwrap)
* [Tips](#tips)
* [fzf inside terminal buffer](#fzf-inside-terminal-buffer)
* [Starting fzf in a Vim popup window](#starting-fzf-in-a-vim-popup-window)
* [Starting fzf in a tmux/Zellij popup window](#starting-fzf-in-a-tmuxzellij-popup-window)
* [Hide statusline](#hide-statusline)
* [License](#license)
<!-- vim-markdown-toc -->
Installation
------------
@@ -153,35 +133,19 @@ let g:fzf_action = {
\ 'ctrl-v': 'vsplit' }
" Default fzf layout
if exists('$TMUX') || exists('$ZELLIJ')
" The Vim plugin will try to open fzf in a tmux or Zellij popup
" if possible (requires recent fzf and tmux/zellij) using --popup option,
" with the following argument:
let g:fzf_layout = { 'popup': '90%,60%' }
else
" If --popup option is not available, it will open in a popup window inside
" Vim (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
endif
" - Popup window (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
" Here are some more layout examples:
" - Tmux or Zellij popup at the bottom 40%
let g:fzf_layout = { 'popup': 'bottom,40%' }
" - Tmux or Zellij popup at the top with a different size
let g:fzf_layout = { 'popup': 'top,90%,40%' }
" - Vim popup window: at the center of the current window (relative)
" - Popup window (center of the current window)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
" - Vim popup window: anchored to the bottom of the current window
" - Popup window (anchored to the bottom of the current window)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true, 'yoffset': 1.0 } }
" - Vim split window: down / up / left / right
" - down / up / left / right
let g:fzf_layout = { 'down': '40%' }
" - Vim window using a Vim command
" - Window using a Vim command
let g:fzf_layout = { 'window': 'enew' }
let g:fzf_layout = { 'window': '-tabnew' }
let g:fzf_layout = { 'window': '10new' }
@@ -332,7 +296,7 @@ The following table summarizes the available options.
| `options` | string/list | Options to fzf |
| `dir` | string | Working directory |
| `up`/`down`/`left`/`right` | number/string | (Layout) Window position and size (e.g. `20`, `50%`) |
| `popup`/`tmux` | string | (Layout) `--popup` options (e.g. `90%,70%`) |
| `tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%`) |
| `window` (Vim 8 / Neovim) | string | (Layout) Command to open fzf window (e.g. `vertical aboveleft 30new`) |
| `window` (Vim 8 / Neovim) | dict | (Layout) Popup window settings (e.g. `{'width': 0.9, 'height': 0.6}`) |
@@ -444,10 +408,10 @@ Tips
### fzf inside terminal buffer
When fzf is configured to start in a terminal buffer inside Vim or Neovim, you
may find the default ANSI colors to be different. In that case, configure the
colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x` in
Neovim.
On the latest versions of Vim and Neovim, fzf will start in a terminal buffer.
If you find the default ANSI colors to be different, consider configuring the
colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x`
in Neovim.
```vim
" Terminal colors for seoul256 color scheme
@@ -478,10 +442,7 @@ else
endif
```
### Starting fzf in a Vim popup window
You can configure fzf to start in a Vim popup window by setting the `window` key
in `g:fzf_layout`.
### Starting fzf in a popup window
```vim
" Required:
@@ -497,32 +458,16 @@ in `g:fzf_layout`.
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
```
### Starting fzf in a tmux/Zellij popup window
fzf can also start in a popup of the multiplexer instead of a window inside
Vim, by putting a `--popup` option value in the `popup` key. `tmux` is
accepted as a synonym, just as `--tmux` is an alias of `--popup`.
The layout works on tmux 3.3 or above, or on Zellij 0.44 or above with fzf
0.71.0 or above. It is the default on tmux 3.7 or above with fzf 0.74.0 or
above, and on Zellij, where the pane is not modal: Vim keeps the window fzf
was started from visible, and you can switch to it while fzf is open. Below
those versions tmux gives a popup that cannot be left, so a window inside Vim
is the default there. On tmux, an explicit `--border` style also gives a modal
popup rather than a floating pane, because the native border of a tmux
floating pane cannot be removed. Drop `--border` to keep the floating pane and
its native border. Zellij keeps the floating pane either way, and hides its
native border when fzf draws one. Set `g:fzf_layout` yourself to choose either
one.
Alternatively, you can make fzf open in a tmux popup window (requires tmux 3.2
or above) by putting `--tmux` option value in `tmux` key.
```vim
if exists('$TMUX') || exists('$ZELLIJ')
" See `--popup` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
let g:fzf_layout = { 'popup': '90%,70%' }
" See `--tmux` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
if exists('$TMUX')
let g:fzf_layout = { 'tmux': '90%,70%' }
else
" Configure the Vim popup window in case not on the multiplexer
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
endif
```
@@ -530,8 +475,7 @@ endif
When fzf starts in a terminal buffer, the file type of the buffer is set to
`fzf`. So you can set up `FileType fzf` autocmd to customize the settings of
the window. This applies to the layouts that open inside Vim, not to the tmux
or Zellij pane the default uses, which is not a buffer.
the window.
For example, if you open fzf on the bottom on the screen (e.g. `{'down':
'40%'}`), you might want to temporarily disable the statusline for a cleaner
+1 -1
View File
@@ -18,7 +18,7 @@ triggered by a tag push.
2. Verify file consistency, sign the tag, and push the tag.
```sh
make tag VERSION=0.74.3
make tag VERSION=0.74.2
```
`make tag` runs `prerelease` first (checks that the version
+20 -59
View File
@@ -14,8 +14,7 @@ FZF - TABLE OF CONTENTS *fzf* *fzf-to
Global options supported by fzf#wrap |fzf-global-options-supported-by-fzf#wrap|
Tips |fzf-tips|
fzf inside terminal buffer |fzf-inside-terminal-buffer|
Starting fzf in a Vim popup window |fzf-starting-fzf-in-a-vim-popup-window|
Starting fzf in a tmux/Zellij popup window |fzf-starting-fzf-in-a-tmuxzellij-popup-window|
Starting fzf in a popup window |fzf-starting-fzf-in-a-popup-window|
Hide statusline |fzf-hide-statusline|
License |fzf-license|
@@ -162,35 +161,19 @@ Examples~
\ 'ctrl-v': 'vsplit' }
" Default fzf layout
if exists('$TMUX') || exists('$ZELLIJ')
" The Vim plugin will try to open fzf in a tmux or Zellij popup
" if possible (requires recent fzf and tmux/zellij) using --popup option,
" with the following argument:
let g:fzf_layout = { 'popup': '90%,60%' }
else
" If --popup option is not available, it will open in a popup window inside
" Vim (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
endif
" - Popup window (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
" Here are some more layout examples:
" - Tmux or Zellij popup at the bottom 40%
let g:fzf_layout = { 'popup': 'bottom,40%' }
" - Tmux or Zellij popup at the top with a different size
let g:fzf_layout = { 'popup': 'top,90%,40%' }
" - Vim popup window: at the center of the current window (relative)
" - Popup window (center of the current window)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
" - Vim popup window: anchored to the bottom of the current window
" - Popup window (anchored to the bottom of the current window)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true, 'yoffset': 1.0 } }
" - Vim split window: down / up / left / right
" - down / up / left / right
let g:fzf_layout = { 'down': '40%' }
" - Vim window using a Vim command
" - Window using a Vim command
let g:fzf_layout = { 'window': 'enew' }
let g:fzf_layout = { 'window': '-tabnew' }
let g:fzf_layout = { 'window': '10new' }
@@ -327,7 +310,7 @@ The following table summarizes the available options.
`options` | string/list | Options to fzf
`dir` | string | Working directory
`up` / `down` / `left` / `right` | number/string | (Layout) Window position and size (e.g. `20` , `50%` )
`popup` / `tmux` | string | (Layout) `--popup` options (e.g. `90%,70%` )
`tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%` )
`window` (Vim 8 / Neovim) | string | (Layout) Command to open fzf window (e.g. `vertical aboveleft 30new` )
`window` (Vim 8 / Neovim) | dict | (Layout) Popup window settings (e.g. `{'width': 0.9, 'height': 0.6}` )
---------------------------+---------------+----------------------------------------------------------------------
@@ -435,8 +418,8 @@ TIPS *fzf-tips*
*fzf-inside-terminal-buffer*
When fzf is configured to start in a terminal buffer inside Vim or Neovim, you
may find the default ANSI colors to be different. In that case, configure the
On the latest versions of Vim and Neovim, fzf will start in a terminal buffer.
If you find the default ANSI colors to be different, consider configuring the
colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x`
in Neovim.
@@ -469,11 +452,8 @@ in Neovim.
endif
<
< Starting fzf in a Vim popup window >________________________________________~
*fzf-starting-fzf-in-a-vim-popup-window*
You can configure fzf to start in a Vim popup window by setting the `window`
key in `g:fzf_layout`.
< Starting fzf in a popup window >____________________________________________~
*fzf-starting-fzf-in-a-popup-window*
>
" Required:
" - width [float range [0 ~ 1]] or [integer range [8 ~ ]]
@@ -487,33 +467,15 @@ key in `g:fzf_layout`.
" - 'rounded' / 'sharp' / 'horizontal' / 'vertical' / 'top' / 'bottom' / 'left' / 'right'
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
<
< Starting fzf in a tmux/Zellij popup window >________________________________~
*fzf-starting-fzf-in-a-tmuxzellij-popup-window*
fzf can also start in a popup of the multiplexer instead of a window inside
Vim, by putting a `--popup` option value in the `popup` key. `tmux` is
accepted as a synonym, just as `--tmux` is an alias of `--popup`.
The layout works on tmux 3.3 or above, or on Zellij 0.44 or above with fzf
0.71.0 or above. It is the default on tmux 3.7 or above with fzf 0.74.0 or
above, and on Zellij, where the pane is not modal: Vim keeps the window fzf
was started from visible, and you can switch to it while fzf is open. Below
those versions tmux gives a popup that cannot be left, so a window inside Vim
is the default there. On tmux, an explicit `--border` style also gives a modal
popup rather than a floating pane, because the native border of a tmux
floating pane cannot be removed. Drop `--border` to keep the floating pane and
its native border. Zellij keeps the floating pane either way, and hides its
native border when fzf draws one. Set `g:fzf_layout` yourself to choose either
one.
Alternatively, you can make fzf open in a tmux popup window (requires tmux 3.2
or above) by putting `--tmux` options in `tmux` key.
>
if exists('$TMUX') || exists('$ZELLIJ')
" See `--popup` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
let g:fzf_layout = { 'popup': '90%,70%' }
" See `--tmux` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
if exists('$TMUX')
let g:fzf_layout = { 'tmux': '90%,70%' }
else
" Configure the Vim popup window in case not on the multiplexer
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
endif
<
@@ -522,8 +484,7 @@ one.
When fzf starts in a terminal buffer, the file type of the buffer is set to
`fzf`. So you can set up `FileType fzf` autocmd to customize the settings of
the window. This applies to the layouts that open inside Vim, not to the tmux
or Zellij pane the default uses, which is not a buffer.
the window.
For example, if you open fzf on the bottom on the screen (e.g. `{'down':
'40%'}`), you might want to temporarily disable the statusline for a cleaner
+1 -1
View File
@@ -2,7 +2,7 @@
set -u
version=0.74.3
version=0.74.2
auto_completion=
key_bindings=
update_config=2
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.3"
$version="0.74.2"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+1 -1
View File
@@ -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
THE SOFTWARE.
..
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.3" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.2" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+1 -1
View File
@@ -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
THE SOFTWARE.
..
.TH fzf 1 "Aug 2026" "fzf 0.74.3" "fzf - a command-line fuzzy finder"
.TH fzf 1 "Aug 2026" "fzf 0.74.2" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
+22 -176
View File
@@ -24,8 +24,7 @@
if exists('g:loaded_fzf')
finish
endif
" Bumped when the plugin gains behavior a caller may need to detect
let g:loaded_fzf = 20260821
let g:loaded_fzf = 1
let s:is_win = has('win32') || has('win64')
if s:is_win && &shellslash
@@ -128,7 +127,7 @@ function! s:fzf_tempname()
return s:fzf_call('tempname')
endfunction
let s:layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right']
let s:layout_keys = ['window', 'tmux', 'up', 'down', 'left', 'right']
let s:fzf_go = s:base_dir.'/bin/fzf'
let s:fzf_tmux = s:base_dir.'/bin/fzf-tmux'
@@ -140,11 +139,6 @@ function! s:popup_support()
endfunction
function! s:default_layout()
" A floating pane leaves the window fzf was started from visible and
" reachable while fzf is open. A popup covers it, inside Vim or not
if s:floating_pane()
return { 'tmux': '90%,60%' }
endif
return s:popup_support()
\ ? { 'window' : { 'width': 0.9, 'height': 0.6 } }
\ : { 'down': '~40%' }
@@ -170,12 +164,6 @@ function! fzf#install()
if v:shell_error
throw 'Failed to download fzf: '.script
endif
" A new binary invalidates the chosen executable and everything derived from
" its version, including whether fzf opens a floating pane. fzf#install() is
" also the vim-plug 'do' hook, so this can run long after the first fzf call
let [s:versions, s:checked] = [{}, {}]
unlet! s:exec s:tmux s:tmux_floating
endfunction
let s:versions = {}
@@ -267,24 +255,8 @@ function! fzf#exec(...)
return s:exec
endfunction
" Path to the fzf-tmux script, or an empty string if it is not available. Only
" the legacy options still need it. --popup is handled by fzf itself.
function! s:fzf_tmux_script()
if !executable(s:fzf_tmux)
if !executable('fzf-tmux')
return ''
endif
let s:fzf_tmux = 'fzf-tmux'
endif
return s:fzf_tmux
endfunction
function! s:tmux_enabled()
if has('gui_running')
return 0
endif
if empty($TMUX) && empty($ZELLIJ)
if has('gui_running') || !exists('$TMUX')
return 0
endif
@@ -292,42 +264,17 @@ function! s:tmux_enabled()
return s:tmux
endif
let [s:tmux, s:tmux_floating] = [0, 0]
" --popup covers Zellij as well, where the fzf-tmux script and the tmux
" version are irrelevant. fzf learned it in 0.71.0, and the floating pane
" options it passes need Zellij 0.44 or above. fzf checks tmux first, so
" this branch is Zellij without tmux. Both non-empty means tmux wins.
" empty(), not exists(), to match how fzf reads the two variables
if empty($TMUX)
let s:tmux =
\ s:compare_versions(s:get_version(s:fzf_binary()), '0.71.0') >= 0
\ && s:compare_versions(s:zellij_version(), '0.44') >= 0
let s:tmux_floating = s:tmux
return s:tmux
let s:tmux = 0
if !executable(s:fzf_tmux)
if executable('fzf-tmux')
let s:fzf_tmux = 'fzf-tmux'
else
return 0
endif
endif
let output = system('tmux -V')
if v:shell_error
return s:tmux
endif
" e.g. 'tmux 3.7b', 'tmux next-3.8'
let ver = matchstr(output, '\d\+\.\d\+')
" --popup requires tmux 3.3 or above, and needs no fzf-tmux script. The
" default layout wants a floating pane, which needs tmux 3.7 or above, fzf
" 0.74.0 or above, and a pane to start from. fzf opens a modal popup when
" any of the three is missing
if s:compare_versions(ver, '3.3') >= 0
let s:tmux = 1
let s:tmux_floating = s:compare_versions(ver, '3.7') >= 0
\ && !empty($TMUX_PANE)
\ && s:compare_versions(s:get_version(s:fzf_binary()), '0.74.0') >= 0
return s:tmux
endif
" Older versions still go through the script
let s:tmux = !empty(s:fzf_tmux_script()) && s:compare_versions(ver, '1.7') >= 0
let s:tmux = !v:shell_error && output >= 'tmux 1.7'
return s:tmux
endfunction
@@ -475,10 +422,7 @@ function! fzf#wrap(...)
if !exists('g:fzf_layout') && exists('g:fzf_height')
let opts.down = g:fzf_height
else
" Not get(), which would evaluate s:default_layout() and run its version
" checks even when g:fzf_layout makes the answer irrelevant
let opts = extend(opts, s:validate_layout(
\ exists('g:fzf_layout') ? g:fzf_layout : s:default_layout()))
let opts = extend(opts, s:validate_layout(get(g:, 'fzf_layout', s:default_layout())))
endif
endif
@@ -558,11 +502,6 @@ try
let [shell, shellslash, shellcmdflag, shellxquote] = s:use_sh()
let dict = exists('a:1') ? copy(a:1) : {}
" 'popup' and 'tmux' are synonyms, as --popup and --tmux are. Normalize here
" so that the rest of the function only has to know about one of them.
if has_key(dict, 'popup')
let dict.tmux = remove(dict, 'popup')
endif
let temps = { 'result': s:fzf_tempname() }
let optstr = s:evaluate_opts(get(dict, 'options', ''))
try
@@ -600,19 +539,16 @@ try
\ executable('tput') && filereadable('/dev/tty')
let has_vim8_term = has('terminal') && has('patch-8.0.995')
let has_nvim_term = has('nvim-0.2.1') || has('nvim') && !s:is_win
let use_term = (has_nvim_term || has_vim8_term) && !s:need_cmd_window
let use_term = has_nvim_term || has_vim8_term
\ && !s:need_cmd_window
\ && (has('gui_running') || s:is_win || s:present(dict, 'down', 'up', 'left', 'right', 'window'))
let use_tmux = (has_key(dict, 'tmux') || (!use_height && !use_term || prefer_tmux) && !has('win32unix') && s:splittable(dict)) && s:tmux_enabled()
if prefer_tmux && use_tmux
let use_height = 0
let use_term = 0
endif
if use_term
let optstr .= ' --no-height'
" Cancel a --popup from $FZF_DEFAULT_OPTS only when the spec asks for a Vim
" window. Without a layout option, respect the user's preference.
if s:present(dict, 'window', 'up', 'down', 'left', 'right')
let optstr .= ' --no-tmux'
endif
let optstr .= ' --no-height --no-tmux'
elseif use_height
let height = s:calc_size(&lines, dict.down, dict)
let optstr .= ' --no-tmux --height='.height
@@ -632,12 +568,8 @@ try
return s:execute_term(dict, command, temps)
endif
" s:execute_tmux may run fzf asynchronously, so it calls s:callback itself
if use_tmux
return s:execute_tmux(dict, command, temps)
endif
let lines = s:execute(dict, command, use_height, temps)
let lines = use_tmux ? s:execute_tmux(dict, command, temps)
\ : s:execute(dict, command, use_height, temps)
call s:callback(dict, lines)
return lines
finally
@@ -654,38 +586,6 @@ function! s:present(dict, ...)
return 0
endfunction
" The binary fzf#exec() would choose, without its prompting or installing.
" Layout selection runs before fzf#exec() has resolved one
function! s:fzf_binary()
if exists('s:exec')
return s:exec
endif
let bins = filter(['fzf', s:fzf_go], 'executable(v:val)')
if empty(bins)
return ''
endif
return len(bins) > 1 ? sort(bins, 's:compare_binary_versions')[-1] : bins[0]
endfunction
function! s:zellij_version()
if !exists('s:zellij_ver')
let output = systemlist('zellij --version')
let s:zellij_ver = v:shell_error || empty(output)
\ ? '' : matchstr(output[0], '[0-9.]\+')
endif
return s:zellij_ver
endfunction
" Whether fzf will open a floating pane that can be left while fzf runs. A
" tmux popup below 3.7 is modal, and the fzf-tmux script that older tmux
" versions need does not accept a --popup value at all. Without a job to wait
" on fzf, s:execute_tmux() blocks on system() and the window stays visible but
" frozen, so the default keeps fzf inside Vim there
function! s:floating_pane()
return (has('nvim') || has('job'))
\ && s:tmux_enabled() && get(s:, 'tmux_floating', 0)
endfunction
function! s:fzf_tmux(dict)
let size = get(a:dict, 'tmux', '')
if empty(size)
@@ -697,15 +597,10 @@ function! s:fzf_tmux(dict)
endfor
endif
" Legacy fzf-tmux options are flags. A --tmux value never starts with a dash,
" but may contain one, as in '90%,60%,border-native'
if size =~ '^-'
let script = s:fzf_tmux_script()
if empty(script)
throw 'fzf-tmux not found, required for the legacy option: ' . size
endif
" Legacy fzf-tmux options
if size =~ '-'
return printf('LINES=%d COLUMNS=%d %s %s %s --',
\ &lines, &columns, fzf#shellescape(script), size, (has_key(a:dict, 'source') ? '' : '-'))
\ &lines, &columns, fzf#shellescape(s:fzf_tmux), size, (has_key(a:dict, 'source') ? '' : '-'))
end
" Using native --tmux option
@@ -847,42 +742,6 @@ function! s:execute(dict, command, use_height, temps) abort
return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
endfunction
" Returns 0 if the job could not be started
function! s:start_popup_job(dict, command, temps) abort
let fzf = { 'dict': a:dict, 'temps': a:temps, 'command': a:command }
" Vim passes (job, status) and Nvim (id, status, event)
function! fzf.on_exit(id, code, ...) abort
redraw!
let lines = s:collect(self.temps)
if s:exit_handler(self.dict, a:code, self.command, 1) >= 2
return
endif
call s:pushd(self.dict)
call s:callback(self.dict, lines)
endfunction
if has('nvim')
return jobstart([&shell, &shellcmdflag, a:command], fzf) > 0
endif
" The command redirects to the result file, so no stream is of interest
let opts = { 'exit_cb': function(fzf.on_exit),
\ 'in_io': 'null', 'out_io': 'null', 'err_io': 'null' }
" job_start() gives the child $TERM=dumb, and the popup inherits the
" environment from here, so fzf would lose its colors. 'env' was added in
" 8.0.902, so set it in the command itself on older versions.
let command = a:command
if has('patch-8.0.902')
let opts.env = { 'TERM': $TERM }
elseif !s:is_win
let command = join(['export TERM=' . fzf#shellescape($TERM) . ';', command])
endif
let job = job_start([&shell, &shellcmdflag, command], opts)
return job_status(job) !=# 'fail'
endfunction
function! s:execute_tmux(dict, command, temps) abort
let command = a:command
let cwd = s:pushd(a:dict)
@@ -891,24 +750,11 @@ function! s:execute_tmux(dict, command, temps) abort
let command = join(['cd', fzf#shellescape(cwd), '&&', command])
endif
" fzf draws in a pane of its own, so the process we start here only waits for
" it. Hold it with a job instead of system() so that Vim keeps processing its
" event loop, and nothing has to be displayed for it. Fall back to the
" blocking path when the job cannot be started, or the sink would never run
" and the temporary files would be left behind.
if (has('nvim') || has('job')) && s:start_popup_job(a:dict, command, a:temps)
" Restore the working directory while fzf runs. on_exit pushes it again
call s:dopopd()
return []
endif
call system(command)
let exit_status = v:shell_error
redraw!
let lines = s:collect(a:temps)
let lines = s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
call s:callback(a:dict, lines)
return lines
return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
endfunction
function! s:calc_size(max, val, dict)
+5 -88
View File
@@ -303,9 +303,7 @@ func bonusAt(input *util.Chars, idx int) int16 {
}
func normalizeRune(r rune) rune {
// Every key of the map folds to ASCII, so a rune the bitmap rejects cannot
// be in it. TestNormalizedKeysAreFlagged verifies that.
if !util.MayFoldToAscii(r) {
if r < 0x00C0 || r > 0xFF61 {
return r
}
@@ -347,90 +345,10 @@ func isAscii(runes []rune) bool {
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) {
// Can't determine
if !input.IsBytes() {
if disableRunePrefilter {
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)
return 0, input.Length()
}
// Not possible
@@ -548,9 +466,8 @@ func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b b
// 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
disableSingle bool
disableTwo bool
)
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
+1 -1
View File
@@ -1,7 +1,7 @@
package algo
// Equivalence tests for the single- and two-character fast paths against the
// general FuzzyMatchV2 algorithm, which serves as the reference.
// general FuzzyMatchV2 algorithm, which serves as the oracle.
//
// Two complementary strategies:
// - Exhaustive: every string up to a fixed length over an alphabet that
-23
View File
@@ -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)
}
-55
View File
@@ -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
}
-128
View File
@@ -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
}
-479
View File
@@ -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
// verifies 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 runs for
// them and Step C has no effect.
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 included in an earlier,
// wider grouping of foldableRanges. General Punctuation matters most:
// curly quotes, en and em dashes and the ellipsis are in it.
{"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
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "",
"漢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 checks 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 run 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 covered by the existing fuzzers
}
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)
}
}
}
+3 -3
View File
@@ -82,10 +82,10 @@ func buildResultFromBounds(item *Item, score int, minBegin, minEnd, maxEnd int,
val = item.TrimLength()
case byPathname:
if validOffsetFound {
// Rune index, to be comparable with minBegin
lastDelim := -1
for i := numChars - 1; i >= 0; i-- {
if r := item.text.Get(i); r == '/' || r == '\\' {
s := item.text.ToString()
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '/' || s[i] == '\\' {
lastDelim = i
break
}
-25
View File
@@ -272,28 +272,3 @@ func TestRadixSortResults(t *testing.T) {
}
}
}
func TestPathnameTiebreak(t *testing.T) {
// FIXME global
sortCriteria = []criterion{byScore, byPathname}
score := 100
test := func(input string, offset Offset, expected uint16) {
for _, chars := range []util.Chars{util.ToChars([]byte(input)), util.RunesToChars([]rune(input))} {
item := buildResult(withIndex(&Item{text: chars}, 1), []Offset{offset}, score)
if item.points[3] != math.MaxUint16-uint16(score) || item.points[2] != expected {
t.Error(input, item.points, expected)
}
}
}
// Match in the file name
test("x/foo/foo.txt", Offset{6, 9}, 1)
// Match in the directory path
test("x/foo/aa.txt", Offset{2, 5}, math.MaxUint16)
// Offsets are rune indexes, so a multi-byte character before the last
// delimiter must not shift the delimiter position
test("一x/foo/foo.txt", Offset{7, 10}, 1)
test("一x/foo/aa.txt", Offset{3, 6}, math.MaxUint16)
}
+2 -52
View File
@@ -58,7 +58,6 @@ var offsetTrimCharsRegex *regexp.Regexp
var passThroughBeginRegex *regexp.Regexp
var passThroughEndTmuxRegex *regexp.Regexp
var sixelBeginRegex *regexp.Regexp
var cursorBackRegex *regexp.Regexp
var ttyin *os.File
var inTmux = len(os.Getenv("TMUX")) > 0
@@ -97,9 +96,6 @@ func init() {
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
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
@@ -2280,14 +2276,6 @@ func (t *Terminal) displayWidthWithPrefix(str string, prefixWidth int) int {
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 (
minWidth = 4
minHeight = 3
@@ -4861,38 +4849,6 @@ func extractPassThroughs(line string) ([]string, string) {
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,
// accounting for line wrapping in the preview window.
func (t *Terminal) followOffset() int {
@@ -6465,11 +6421,7 @@ func (t *Terminal) Loop() error {
version--
offset = 0
}
if split := t.splitOnIND(line); split != nil {
lines = append(lines, split...)
} else {
lines = append(lines, line)
}
lines = append(lines, line)
}
if err != nil {
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
@@ -7417,9 +7369,7 @@ func (t *Terminal) Loop() error {
case actReplaceQuery:
current := t.currentItem()
if current != nil {
// ToRunes aliases the item text in rune mode, and the
// editing actions below append into t.input in place
t.input = append([]rune{}, current.text.ToRunes()...)
t.input = current.text.ToRunes()
t.cx = len(t.input)
}
case actFatal:
+8 -84
View File
@@ -29,9 +29,6 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter
}
func TestReplacePlaceholder(t *testing.T) {
// Pin $SHELL so the quoting style doesn't depend on the test runner's shell
t.Setenv("SHELL", "cmd")
item1 := newItem(" foo'bar \x1b[31mbaz\x1b[m")
items1 := [3][]*Item{{item1}, {item1}, nil}
items2 := [3][]*Item{
@@ -226,7 +223,7 @@ func TestReplacePlaceholder(t *testing.T) {
// while the double q is invalid, it is useful here for testing purposes
templateToOutput[`{q}`] = "{{.O}}" + query + "{{.O}}"
templateToOutput[`{fzf:query}`] = "{{.O}}" + query + "{{.O}}"
templateToOutput[`{fzf:action} {fzf:prompt}`] = `backward-delete-char-eof {{.O}}prompt{{.O}}`
templateToOutput[`{fzf:action} {fzf:prompt}`] = "backward-delete-char-eof 'prompt'"
// IV. escaping placeholder
templateToOutput[`\{}`] = `{}`
@@ -254,13 +251,10 @@ func TestReplacePlaceholder(t *testing.T) {
}
func TestQuoteEntry(t *testing.T) {
type quotes struct{ E, O, SQ, DQ, BS, PB string } // standalone escape, outer, single and double quotes, doubled and plain backslash
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`, `\`}
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`, `\`}
type quotes struct{ E, O, SQ, DQ, BS string } // standalone escape, outer, single and double quotes, backslash
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`}
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`}
var effectiveStyle quotes
// Pin $SHELL so the quoting style doesn't depend on the test runner's shell
t.Setenv("SHELL", "cmd")
exec := util.NewExecutor("")
if util.IsWindows() {
@@ -286,13 +280,13 @@ func TestQuoteEntry(t *testing.T) {
`>`: `{{.O}}{{.E}}>{{.O}}`,
`(`: `{{.O}}{{.E}}({{.O}}`,
`)`: `{{.O}}{{.E}}){{.O}}`,
`@`: `{{.O}}@{{.O}}`,
`@`: `{{.O}}{{.E}}@{{.O}}`,
`^`: `{{.O}}{{.E}}^{{.O}}`,
`%`: `{{.O}}{{.E}}%{{.O}}`,
`!`: `{{.O}}{{.E}}!{{.O}}`,
`%USERPROFILE%`: `{{.O}}{{.E}}%USERPROFILE{{.E}}%{{.O}}`,
`C:\Program Files (x86)\`: `{{.O}}C:{{.PB}}Program Files {{.E}}(x86{{.E}}){{.BS}}{{.O}}`,
`"C:\Program Files"`: `{{.O}}{{.DQ}}C:{{.PB}}Program Files{{.DQ}}{{.O}}`,
`C:\Program Files (x86)\`: `{{.O}}C:{{.BS}}Program Files {{.E}}(x86{{.E}}){{.BS}}{{.O}}`,
`"C:\Program Files"`: `{{.O}}{{.DQ}}C:{{.BS}}Program Files{{.DQ}}{{.O}}`,
}
for input, expected := range tests {
@@ -446,7 +440,7 @@ func TestPowershellCommands(t *testing.T) {
// to explorer, which will prompt user to pick editing program for the fzf-preview file
// the temp file contains: `cat "C:\test.txt"`
// TODO this should actually work
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-temp-[0-9]+$`}},
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-preview-[0-9]{9}$`}},
}
// to force powershell-style escaping we temporarily set environment variable that fzf honors
@@ -857,73 +851,3 @@ func TestWordWrapAnsiLine(t *testing.T) {
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])
}
}
}
}
+5 -109
View File
@@ -24,45 +24,15 @@ const (
defaultEscDelay = 100
escPollInterval = 5
offsetPollTries = 10
queryTimeout = 500 * time.Millisecond
maxInputBuffer = 1024 * 1024
escapeLookback = 256
maxSelectTries = 100
)
const DefaultTtyDevice string = "/dev/tty"
var offsetRegexp = regexp.MustCompile("\x00?\x1b\\[([0-9]+);([0-9]+)R")
var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
// DECRPM reply to the DECRQM query for bracketed paste mode. Ps is 1 or 3 when
// the mode was already set, 2 or 4 when reset, 0 when the terminal does not
// recognize the mode.
var pasteModeRegexp = regexp.MustCompile("\x00?\x1b\\[\\?2004;([0-4])\\$y")
var pasteModeRegexpBegin = regexp.MustCompile("^\x1b\\[\\?2004;[0-4]\\$y")
// A report to ask the terminal for, and the reply to recognize it by.
type termQuery struct {
seq string
reply *regexp.Regexp
}
var offsetQuery = termQuery{"6n", offsetRegexp}
var pasteModeQuery = termQuery{"?2004$p", pasteModeRegexp}
// What we ask the terminal at startup, in the order the queries go out.
// A terminal answers them in that order, so the cursor position query is last
// and also ends the wait: every terminal fzf supports answers it, so once its
// reply arrives, a query still unanswered is one the terminal does not know
// rather than one we stopped waiting for too early.
//
// Terminals that don't support the paste mode query (DECRQM) might leave
// 'p' on the screen. To handle such cases, we query the position before and
// after it, compare them, and clean the artifact if they don't match.
//
// Reference: https://ansicode.eversources.app/en/sequence/decrqm
var startupQueries = []termQuery{offsetQuery, pasteModeQuery, offsetQuery}
func (r *LightRenderer) Bell() {
r.flushRaw("\a")
}
@@ -188,10 +158,6 @@ type LightRenderer struct {
showCursor bool
mutex sync.Mutex
// Whether bracketed paste was already on before we enabled it. Nil when
// the terminal did not answer the query.
pasteWasSet *bool
// Windows only
ttyinChannel chan byte
inHandle uintptr
@@ -264,13 +230,8 @@ func (r *LightRenderer) Init() error {
if r.fullscreen {
r.smcup()
}
// Ask everything in one round trip, before the offset is needed.
y, x, pasteWasSet := r.queryStartup()
r.pasteWasSet = pasteWasSet
if !r.fullscreen {
} else {
y, x := r.findOffset()
r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small
// time frame between fzf processes where the user keystrokes are not
@@ -339,45 +300,6 @@ func getEnv(name string, defaultValue int) int {
return atoi(env, defaultValue)
}
// Bytes of a CSI sequence: parameter and intermediate bytes continue it, a
// final byte ends it. Order is not enforced. Strictness would only make fzf
// give up on a sequence it could have framed.
//
// https://vt100.net/emu/dec_ansi_parser
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
// incompleteEscape reports whether the buffer ends in an escape sequence that
// has not been terminated yet. The read loop keeps waiting in that case, so the
// parser is never handed a fragment to guess at.
func incompleteEscape(buffer []byte) bool {
// Only the tail can hold a sequence still arriving. This runs once per byte
// read, so scanning all of a large paste would make the read quadratic.
tail := buffer
if len(tail) > escapeLookback {
tail = tail[len(tail)-escapeLookback:]
}
start := bytes.LastIndexByte(tail, Esc.Byte())
if start < 0 || len(tail)-start < 2 {
return false
}
switch tail[start+1] {
case '[':
for _, b := range tail[start+2:] {
if csiFinal(b) {
return false
}
if !csiContinues(b) {
return false // malformed, do not wait for a terminator
}
}
return true
case 'O':
return len(tail)-start < 3
}
return false
}
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
return r.getBytesInternal(cancellable, r.buffer, false)
}
@@ -396,11 +318,7 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
if c == Esc.Int() || nonblock {
retries = r.escDelay / escPollInterval
}
// A non-blocking read that found nothing has no byte to record. Recording
// one would put a NUL in the middle of a reply still being assembled.
if result.ok() {
buffer = append(buffer, byte(c))
}
buffer = append(buffer, byte(c))
pc := c
for {
@@ -418,13 +336,6 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
retries = 0
}
buffer = append(buffer, byte(c))
// Keep waiting while a sequence is still arriving. Dropping the budget
// after every byte left fzf parsing whatever the read happened to end on.
// Past the introducer this is not the ESC key, so the wait costs no
// Escape latency and ESCDELAY=0 must not reduce it to nothing.
if retries == 0 && incompleteEscape(buffer) {
retries = max(r.escDelay, defaultEscDelay) / escPollInterval
}
pc = c
// This should never happen under normal conditions,
@@ -535,12 +446,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{Invalid, 0, nil}
}
loc = pasteModeRegexpBegin.FindIndex(r.buffer)
if loc != nil && loc[0] == 0 {
*sz = loc[1]
return Event{Invalid, 0, nil}
}
*sz = 2
if r.buffer[1] == 8 {
return Event{CtrlAltBackspace, 0, nil}
@@ -1114,16 +1019,7 @@ func (r *LightRenderer) disableMouse() {
func (r *LightRenderer) disableModes() {
r.disableMouse()
// Put bracketed paste back the way we found it. A shell that runs fzf from
// a line editor widget re-enables the mode only when the editor starts, so
// forcing it off here would leave it off for the rest of the session.
// Terminals that did not answer the query fall back to disabling, which is
// what fzf has always done.
if r.pasteWasSet != nil && *r.pasteWasSet {
r.csi("?2004h")
} else {
r.csi("?2004l")
}
r.csi("?2004l")
}
func (r *LightRenderer) Resume(clear bool, sigcont bool) {
-53
View File
@@ -1,53 +0,0 @@
package tui
import (
"strings"
"testing"
)
func TestIncompleteEscape(t *testing.T) {
for _, c := range []struct {
buffer string
want bool
}{
// Complete sequences: nothing to wait for
{"\x1b[A", false},
{"\x1bOA", false},
{"\x1b[1;5A", false},
{"\x1b[200~", false},
{"\x1b[<0;1;1M", false},
{"\x1b[12;34R", false},
{"\x1b[?2004;2$y", false},
{"\x1b[?1;2c", false},
// Fragments: keep waiting
{"\x1b[", true},
{"\x1b[?", true},
{"\x1b[1;", true},
{"\x1b[?2004;2$", true},
{"\x1bO", true},
{"\x1b[<0;1;", true},
// Only the trailing sequence matters
{"ab\x1b[?2004;2$", true},
{"\x1b[A\x1b[", true},
{"\x1b[A\x1b[B", false},
// Long buffers: only the tail is scanned, so an introducer further
// back than escapeLookback is not waited for
{strings.Repeat("a", 100000), false},
{"\x1b[" + strings.Repeat("a", 100000), false},
{strings.Repeat("a", 100000) + "\x1b[1;", true},
// Not a sequence fzf waits on
{"", false},
{"abc", false},
{"\x1b", false}, // lone ESC, handled by the existing escDelay branch
{"\x1ba", false}, // ALT-a
{"\x1b[\x01", false}, // malformed, do not stall on it
} {
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
}
}
}
-128
View File
@@ -1,128 +0,0 @@
//go:build !windows
package tui
import (
"os"
"strings"
"testing"
)
// Drives queryStartup against a terminal simulated by pipes, with the replies
// already queued so the exchange is deterministic.
func replyingTerminal(t *testing.T, replies string) (*LightRenderer, func() string) {
t.Helper()
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
outR, outW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
if _, err := inW.WriteString(replies); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { inR.Close(); inW.Close(); outR.Close() })
r := &LightRenderer{ttyin: inR, ttyout: outW, escDelay: defaultEscDelay}
written := func() string {
outW.Close()
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := outR.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
return sb.String()
}
return r, written
}
const eraseEcho = "\b \b"
func TestQueryStartup(t *testing.T) {
for _, tc := range []struct {
name string
replies string
row, col int
paste string // "", "true" or "false"
erase int // columns the echo took, and so erasures expected
}{
{
name: "mode already set",
replies: "\x1b[5;10R\x1b[?2004;1$y\x1b[5;10R",
row: 4, col: 9, paste: "true",
},
{
name: "mode reset",
replies: "\x1b[5;10R\x1b[?2004;2$y\x1b[5;10R",
row: 4, col: 9, paste: "false",
},
{
name: "mode not recognized",
replies: "\x1b[5;10R\x1b[?2004;0$y\x1b[5;10R",
row: 4, col: 9,
},
{
// Answers the position queries but ignores DECRQM without printing
name: "query ignored",
replies: "\x1b[5;10R\x1b[5;10R",
row: 4, col: 9,
},
{
// macOS Terminal.app: ends the sequence at '$' and prints the 'p'
name: "query echoed",
replies: "\x1b[5;10R\x1b[5;11R",
row: 4, col: 9, erase: 1,
},
{
// A terminal that prints more of what it could not parse
name: "longer echo",
replies: "\x1b[5;10R\x1b[5;13R",
row: 4, col: 9, erase: 3,
},
{
// The echo wrapped to the next line, where erasing would guess
name: "echo wrapped",
replies: "\x1b[5;80R\x1b[6;1R",
row: 4, col: 79,
},
{
name: "no reply at all",
replies: "",
row: -1, col: -1,
},
} {
t.Run(tc.name, func(t *testing.T) {
r, written := replyingTerminal(t, tc.replies)
row, col, pasteWasSet := r.queryStartup()
if row != tc.row || col != tc.col {
t.Errorf("offset (%d,%d), want (%d,%d)", row, col, tc.row, tc.col)
}
paste := ""
if pasteWasSet != nil {
paste = "false"
if *pasteWasSet {
paste = "true"
}
}
if paste != tc.paste {
t.Errorf("pasteWasSet %q, want %q", paste, tc.paste)
}
out := written()
if got := strings.Count(out, eraseEcho); got != tc.erase {
t.Errorf("erased %d columns, want %d (wrote %q)", got, tc.erase, out)
}
// The paste mode query has to sit between two position queries
if want := "\x1b[6n\x1b[?2004$p\x1b[6n"; !strings.Contains(out, want) {
t.Errorf("queries %q, want %q in order", out, want)
}
})
}
}
-1
View File
@@ -434,7 +434,6 @@ func TestLightRendererSGRDeduplication(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer out.Close()
r.ttyout = out
w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow)
+18 -114
View File
@@ -8,7 +8,6 @@ import (
"os/exec"
"strings"
"syscall"
"time"
"github.com/junegunn/fzf/src/util"
"golang.org/x/sys/unix"
@@ -94,120 +93,25 @@ func (r *LightRenderer) updateTerminalSize() {
}
}
// waitReadable reports whether the tty has something to read before the
// deadline. A terminal that does not recognize a query answers nothing at all,
// so the read that follows must be able to stop waiting, or fzf would wait for
// the user to press a key instead of drawing itself. The timeout is generous
// because a terminal that does answer exceeds it only when the link is slow
// enough to be unusable anyway.
func (r *LightRenderer) waitReadable(timeout time.Duration) bool {
fd := r.fd()
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
var rfds unix.FdSet
if fd >= len(rfds.Bits)*unix.NFDBITS {
return false
}
rfds.Set(fd)
// Recomputed each time round: Linux select rewrites the timeout with
// the time left, other systems leave it alone
tv := unix.NsecToTimeval(int64(remaining))
n, err := unix.Select(fd+1, &rfds, nil, nil, &tv)
if err == syscall.EINTR {
continue
}
if err != nil {
// Nothing was confirmed readable, and the read that follows
// reports the failure if the fd is really broken
return false
}
return n > 0
}
}
// queryTerminal sends every query in a single write and reads until the last
// one is answered. Returns the submatches of each reply, nil for a query the
// terminal ignored. Replies are cut out of what we read as they are recognized,
// so whatever is left over is input the user typed during the round trip.
func (r *LightRenderer) queryTerminal(queries []termQuery) [][][]byte {
for _, query := range queries {
r.csi(query.seq)
}
r.flush()
replies := make([][][]byte, len(queries))
buffer := []byte{}
for tries := range offsetPollTries {
// Only the first read blocks, so that is the one to put a bound on
if tries == 0 && !r.waitReadable(queryTimeout) {
return replies
}
var err error
buffer, _, err = r.getBytesInternal(false, buffer, tries > 0)
if err != nil {
return replies
}
for idx, query := range queries {
if replies[idx] != nil {
continue
}
loc := query.reply.FindSubmatchIndex(buffer)
if loc == nil {
continue
}
groups := make([][]byte, len(loc)/2)
for group := range groups {
if loc[group*2] >= 0 {
groups[group] = buffer[loc[group*2]:loc[group*2+1]]
}
}
replies[idx] = groups
// Capping the prefix makes append copy, leaving groups valid
buffer = append(buffer[:loc[0]:loc[0]], buffer[loc[1]:]...)
}
if replies[len(queries)-1] != nil {
break
}
}
r.buffer = append(r.buffer, buffer...)
return replies
}
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
replies := r.queryTerminal(startupQueries)
before, paste, after := replies[0], replies[1], replies[2]
if paste != nil && paste[1][0] != '0' {
// 1 = set, 3 = permanently set
set := paste[1][0] == '1' || paste[1][0] == '3'
pasteWasSet = &set
}
row, col = parseOffset(before)
// The cursor moved right likely because terminal doesn't support DECRQM
if row2, col2 := parseOffset(after); row >= 0 && row2 == row && col2 > col {
r.flushRaw(strings.Repeat("\b \b", col2-col))
}
return
}
func parseOffset(reply [][]byte) (row int, col int) {
if reply == nil {
return -1, -1
}
return atoi(string(reply[1]), 0) - 1, atoi(string(reply[2]), 0) - 1
}
func (r *LightRenderer) findOffset() (row int, col int) {
return parseOffset(r.queryTerminal([]termQuery{offsetQuery})[0])
r.csi("6n")
r.flush()
var err error
bytes := []byte{}
for tries := range offsetPollTries {
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
if err != nil {
return -1, -1
}
offsets := offsetRegexp.FindSubmatch(bytes)
if len(offsets) > 3 {
// Add anything we skipped over to the input buffer
r.buffer = append(r.buffer, offsets[1]...)
return atoi(string(offsets[2]), 0) - 1, atoi(string(offsets[3]), 0) - 1
}
}
return -1, -1
}
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
-7
View File
@@ -151,13 +151,6 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
}
// The console API answers for the cursor, and there is no reply to parse for
// bracketed paste, so fzf keeps disabling the mode on exit here.
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
row, col = r.findOffset()
return
}
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
if !nonblock && !cancellable {
bc := <-r.ttyinChannel
+9 -142
View File
@@ -3,7 +3,6 @@ package util
import (
"bytes"
"fmt"
"math/bits"
"unicode"
"unicode/utf8"
"unsafe"
@@ -14,17 +13,9 @@ const (
overflow32 uint32 = 0x80808080
)
const (
flagInBytes uint8 = 1 << iota
flagMayFold
)
type Chars struct {
slice []byte // or []rune
// Only ever set, never cleared, so a reader racing a Prepend sees either
// the old or the new value and both are safe. trimLength* is kept out
// because TrimLength rewrites it.
flags uint8
slice []byte // or []rune
inBytes bool
trimLengthKnown bool
trimLength uint16
@@ -33,59 +24,6 @@ type Chars struct {
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 verifies. Grouped tightly on purpose: a wider merge would include
// 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) {
i := 0
for ; i <= len(bytes)-8; i += 8 {
@@ -106,94 +44,31 @@ func checkAscii(bytes []byte) (bool, int) {
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
func ToChars(bytes []byte) Chars {
inBytes, bytesUntil := checkAscii(bytes)
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 {
runes[i] = rune(bytes[i])
}
mayFold := false
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:])
i += sz
mayFold = mayFold || MayFoldToAscii(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 {
mayFold := 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}
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false}
}
func (chars *Chars) IsBytes() bool {
return chars.flags&flagInBytes != 0
}
// 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()
return chars.inBytes
}
func (chars *Chars) Bytes() []byte {
@@ -230,7 +105,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) {
}
func (chars *Chars) optionalRunes() []rune {
if chars.IsBytes() {
if chars.inBytes {
return nil
}
return *(*[]rune)(unsafe.Pointer(&chars.slice))
@@ -252,7 +127,7 @@ func (chars *Chars) Length() int {
// String returns the string representation of a Chars object.
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
@@ -343,8 +218,6 @@ func (chars *Chars) ToString() string {
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 {
if runes := chars.optionalRunes(); runes != nil {
return runes
@@ -374,12 +247,6 @@ func (chars *Chars) Prepend(prefix string) {
} else {
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) {
+20 -162
View File
@@ -2,106 +2,19 @@ package util
import (
"fmt"
"math/rand"
"strings"
"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) {
chars := ToChars([]byte("foobar"))
if !chars.IsBytes() || chars.ToString() != "foobar" {
if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes {
t.Error()
}
}
func TestCharsLength(t *testing.T) {
chars := ToChars([]byte("\tabc한글 "))
if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 {
if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 {
t.Error()
}
}
@@ -194,80 +107,25 @@ func TestCharsLinesWrapWord(t *testing.T) {
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
}
// Tab as word boundary
chars3 := ToChars([]byte("hello\tworld"))
lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true)
// "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7)
// Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5
// Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1])
// Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found
// Falls back to character wrap: "hello" | "\tworld"
if len(lines3) < 2 {
t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3)
}
// wrapWord=false still character-wraps
chars3 := ToChars([]byte("hello world"))
lines3, _ := chars3.Lines(false, 100, 8, 0, 8, false)
if len(lines3) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines3), lines3)
chars4 := ToChars([]byte("hello world"))
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
if len(lines4) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
}
if string(lines3[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines3[0]))
}
}
// Chars is one per input line, so its size matters. 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
// verifies 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)
if string(lines4[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
}
}
-13
View File
@@ -479,19 +479,6 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal '> 10', lines[-1] }
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
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] }
+1 -1
View File
@@ -973,7 +973,7 @@ class TestZsh < TestBase
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') })
assert_equal 1, lines.count { |l| l.include?('chpwd hook fired') }
end
end
+12 -92
View File
@@ -4,44 +4,15 @@ Execute (Setup):
Log 'Test directory: ' . g:dir
Save &acd
" fzf#run runs fzf in a terminal buffer and returns immediately, so the
" assertions have to wait for the run to finish. 'exit' is called from the
" same handler that runs the sink, just before it, so once it has fired the
" sink has run too. It also fires when nothing is selected, unlike the sink.
function! g:FzfExit(code)
let g:fzf_done = 1
endfunction
" Collects the output without displacing a 'sink' the spec already has.
" s:callback runs both
function! g:FzfCollect(lines)
call extend(g:fzf_lines, a:lines)
endfunction
function! g:FzfRun(spec) abort
let g:fzf_done = 0
let g:fzf_lines = []
call fzf#run(extend(copy(a:spec),
\ { 'exit': function('g:FzfExit'), 'sinklist': function('g:FzfCollect') }))
let started = reltime()
while !g:fzf_done && reltimefloat(reltime(started)) < 10
sleep 10m
endwhile
if !g:fzf_done
throw 'Timed out waiting for fzf#run'
endif
return g:fzf_lines
endfunction
Execute (fzf#run with dir option):
let cwd = getcwd()
let result = g:FzfRun({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir })
let result = fzf#run({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir })
AssertEqual ['fzf.vader'], result
AssertEqual 0, haslocaldir()
AssertEqual getcwd(), cwd
execute 'lcd' fnameescape(cwd)
let result = sort(g:FzfRun({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir }))
let result = sort(fzf#run({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir }))
AssertEqual ['fzf.vader'], result
AssertEqual 1, haslocaldir()
AssertEqual getcwd(), cwd
@@ -51,32 +22,32 @@ Execute (fzf#run with Funcref command):
function! g:FzfTest(e)
call add(g:ret, a:e)
endfunction
let result = sort(g:FzfRun({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir }))
let result = sort(fzf#run({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir }))
AssertEqual ['fzf.vader'], result
AssertEqual ['fzf.vader'], sort(g:ret)
Execute (fzf#run with string source):
let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' }))
let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' }))
AssertEqual ['hi'], result
Execute (fzf#run with list source):
let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f e' }))
let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f e' }))
AssertEqual ['hello'], result
let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f o' }))
let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f o' }))
AssertEqual ['hello', 'world'], result
Execute (fzf#run with string source):
let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' }))
let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' }))
AssertEqual ['hi'], result
Execute (fzf#run with dir option and noautochdir):
set noacd
let cwd = getcwd()
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
" No change in working directory
AssertEqual cwd, getcwd()
call g:FzfRun({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'})
call fzf#run({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'})
AssertEqual cwd, getcwd()
tabclose
AssertEqual cwd, getcwd()
@@ -84,13 +55,13 @@ Execute (fzf#run with dir option and noautochdir):
Execute (Incomplete fzf#run with dir option and autochdir):
set acd
let cwd = getcwd()
call g:FzfRun({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'})
call fzf#run({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'})
" No change in working directory even if &acd is set
AssertEqual cwd, getcwd()
Execute (FIXME: fzf#run with dir option and autochdir):
set acd
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
" Working directory changed due to &acd
AssertEqual '/foobar', expand('%')
AssertEqual '/', getcwd()
@@ -98,40 +69,13 @@ Execute (FIXME: fzf#run with dir option and autochdir):
Execute (fzf#run with dir option and autochdir when final cwd is same as dir):
set acd
cd /tmp
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'})
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'})
" Working directory changed due to &acd
AssertEqual '/', getcwd()
Execute (Default layout):
unlet! g:fzf_layout g:fzf_height
let layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right']
let opts = fzf#wrap('foobar')
Log opts
let found = filter(copy(layout_keys), 'has_key(opts, v:val)')
AssertEqual 1, len(found)
if found[0] ==# 'tmux'
" Only where fzf opens a floating pane, which can be left while fzf runs
Assert !empty($TMUX) || !empty($ZELLIJ)
AssertEqual '90%,60%', opts.tmux
elseif found[0] ==# 'window'
AssertEqual 0.9, opts.window.width
else
" No popup support in this build
AssertEqual '~40%', opts.down
endif
" Fullscreen strips it, whichever it was
let opts = fzf#wrap('foobar', {}, 1)
Log opts
AssertEqual [], filter(copy(layout_keys), 'has_key(opts, v:val)')
Execute (fzf#wrap):
AssertThrows fzf#wrap({'foo': 'bar'})
" Pin the layout so the assertions do not depend on the environment
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
let opts = fzf#wrap('foobar')
Log opts
AssertEqual 0.9, opts.window.width
@@ -206,30 +150,6 @@ Execute (fzf#wrap):
let opts = fzf#wrap({})
Assert opts.options =~ '--color=fg:'
Execute (popup is a synonym of tmux):
unlet! g:fzf_layout
" Treated as a layout option, so g:fzf_layout is not applied on top
let opts = fzf#wrap('foobar', {'popup': '90%,70%'})
AssertEqual '90%,70%', opts.popup
Assert !has_key(opts, 'window')
" And stripped in fullscreen, like the others
let opts = fzf#wrap('foobar', {'popup': '90%,70%'}, 1)
Assert !has_key(opts, 'popup')
" Accepted in g:fzf_layout
let g:fzf_layout = {'popup': '90%,70%'}
let opts = fzf#wrap('foobar')
AssertEqual '90%,70%', opts.popup
unlet g:fzf_layout
" Listed as a valid key
let g:fzf_layout = {'bogus': 1}
AssertThrows call fzf#wrap('foobar')
Assert g:vader_exception =~ 'popup'
unlet g:fzf_layout
Execute (fzf#shellescape with sh):
AssertEqual '''''', fzf#shellescape('', 'sh')
AssertEqual '''\''', fzf#shellescape('\', 'sh')