mirror of
https://github.com/junegunn/fzf.git
synced 2026-10-26 20:16:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ff936df0 | ||
|
|
b412a45053 | ||
|
|
ac28b8c3ba | ||
|
|
2adb741c33 | ||
|
|
a38ab117c5 | ||
|
|
903a4da7ab | ||
|
|
d3d6e40962 | ||
|
|
c943963281 | ||
|
|
a0f6bac731 | ||
|
|
55c122f1a0 | ||
|
|
c8c2d0828a | ||
|
|
c0a193e031 | ||
|
|
cbc11e4d95 | ||
|
|
f126ff2954 | ||
|
|
d8aeec3058 | ||
|
|
981f1539b0 | ||
|
|
25adb67453 | ||
|
|
33a3456921 | ||
|
|
b563e208d6 | ||
|
|
c009c106b9 | ||
|
|
b1be3a8be1 | ||
|
|
961793cf39 | ||
|
|
a140afeb4d | ||
|
|
758b1ac73d | ||
|
|
013532d5cd | ||
|
|
fe04475d24 | ||
|
|
b224480a98 | ||
|
|
1578b7c3c3 | ||
|
|
63e82a9e3d | ||
|
|
a1e983baf1 | ||
|
|
ad151d8415 | ||
|
|
7e6339979f | ||
|
|
1372d04f79 | ||
|
|
a52ac843d7 | ||
|
|
52f4319a72 | ||
|
|
b395cfbd91 | ||
|
|
f7ae439ff5 | ||
|
|
d46bfd005a | ||
|
|
d0377ed4dc | ||
|
|
5cb7bab702 | ||
|
|
956562daaa | ||
|
|
15f64c492a | ||
|
|
bd4efa277b | ||
|
|
63f6cfec5b | ||
|
|
d4b6ba781c | ||
|
|
85a2a33612 | ||
|
|
ca4c1b80e3 | ||
|
|
dab626bd9e | ||
|
|
2885df8395 | ||
|
|
715d26fa39 | ||
|
|
ab1ee05e51 | ||
|
|
7b16e44f53 | ||
|
|
9dfdba41f5 | ||
|
|
465837f3ad | ||
|
|
a650900eda | ||
|
|
793e58b558 | ||
|
|
759b7c3283 | ||
|
|
0579bb0e0d |
No files matched your search
@@ -3,10 +3,6 @@ name: Issue Template
|
||||
description: Report a problem or bug related to fzf to help us improve
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: ISSUES NOT FOLLOWING THIS TEMPLATE WILL BE CLOSED AND DELETED
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
@@ -32,7 +28,6 @@ body:
|
||||
- label: Linux
|
||||
- label: macOS
|
||||
- label: Windows
|
||||
- label: Etc.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
@@ -41,9 +36,23 @@ body:
|
||||
- label: bash
|
||||
- label: zsh
|
||||
- label: fish
|
||||
- label: nushell
|
||||
- label: PowerShell
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem / Steps to reproduce
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: How did you run into this?
|
||||
description: |
|
||||
What were you actually trying to do? Include the command line or
|
||||
configuration from your real setup.
|
||||
|
||||
If you did not hit this in actual use, say how you found it
|
||||
(reading the code, automated analysis, etc.).
|
||||
validations:
|
||||
required: true
|
||||
@@ -38,7 +38,11 @@ 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
|
||||
sudo apt-get install --yes zsh fish tmux shfmt nushell
|
||||
# Pinned: the Nushell scripts now require 0.116+, where an external
|
||||
# completer reaches the terminal through the @interactive attribute.
|
||||
# Before that (e.g. 0.115.0), completers run 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.116.1-0
|
||||
|
||||
- name: Install Ruby gems
|
||||
run: bundle install
|
||||
|
||||
@@ -1,6 +1,94 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.74.6
|
||||
------
|
||||
- Fixed `--with-shell` on Windows not detecting cmd, PowerShell, and pwsh, which made fzf quote commands and entries for a POSIX shell (#4936) (@vimkim)
|
||||
- e.g. With `--with-shell "cmd.exe /d /c"`, `--preview '"C:\Program Files\nu\bin\nu.exe" --version'` failed
|
||||
- `transform-nth` and `transform-with-nth` (and their `bg-` variants) no longer split the output at `|`
|
||||
- Only `change-nth` and `change-with-nth` rotate through `|`-separated values
|
||||
- Fixed the bound command being replaced with the output. e.g. With `transform-nth(echo '1|2')`, the next key press ran `2|1` as a shell command.
|
||||
- A `--with-nth` template in the output can now contain `|`. e.g. `{2}|{1}` was cut to `{2}`.
|
||||
- `change-nth` and `transform-nth` now handle empty and invalid values the same way as `change-with-nth` and `transform-with-nth`
|
||||
- An empty value restores the default `--nth`. Previously, `change-nth()` and an empty `transform-nth` output searched all fields. Use `change-nth(..)` for that.
|
||||
- A non-empty invalid value is ignored. Previously, it restored the default `--nth`.
|
||||
- `change-nth` and `change-with-nth` now apply the value after the current one in the `|`-separated list
|
||||
- A value equal to the current one is skipped, e.g. the first value when it is the initial `--nth`
|
||||
- Invalid values in the list are skipped
|
||||
- The rotation follows the current value even when another binding changed it, or when the action is printed by `transform` (unless the list contains the same value more than once)
|
||||
- You can list the values in their natural order, starting with the initial one
|
||||
```sh
|
||||
# Previously, you would have to write:
|
||||
ls -al | fzf --with-nth .. --bind 'space:change-with-nth:1|2|..'
|
||||
|
||||
# Now you can write:
|
||||
ls -al | fzf --with-nth .. --bind 'space:change-with-nth:..|1|2'
|
||||
```
|
||||
- nushell
|
||||
- The completion integration now requires nushell 0.116.1 or above, where an interactive completer can take the terminal through the `@interactive` attribute (#4934) (@sim590)
|
||||
- Fixed `--no-sort` being ignored in filter mode with `--sync`, `--tac`, or `--bench` (#4940) (@NavpreetSSidhu)
|
||||
- Vim plugin: Fixed garbage text on screen in Neovim with `set title` when the fzf command contains ANSI escape sequences (#4941)
|
||||
- Fixed `--walker=follow` skipping a symlink in one `--walker-root` when its target is an ancestor of another root (#4944) (@mabunemeh)
|
||||
- Fixed O(n^2) action parsing in `--listen`. A single ~1 MB request could block the server for ~30 s (@zachariah-mithani, AploSec)
|
||||
|
||||
0.74.5
|
||||
------
|
||||
- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari)
|
||||
- On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a floating pane is created over a zoomed window
|
||||
- Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920)
|
||||
- Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926)
|
||||
- Fixed a key lost when it arrived in the same read as an escape sequence
|
||||
- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930)
|
||||
- With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key
|
||||
- A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change
|
||||
- Security fixes
|
||||
- fzf-tmux no longer evaluates an attached layout size (e.g. `-l50`) via bash arithmetic, which could run commands or inject tmux flags (GHSA-w6x2-f5m4-xw82)
|
||||
- zsh completion now creates its FIFO in a private `mktemp -d` directory instead of a predictable `/tmp` path (GHSA-7rj6-7pqx-v7rm)
|
||||
- Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K (Researchers) of Innspark Solutions
|
||||
|
||||
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)
|
||||
- Fixed `progress` in the `--listen` status payload staying at 100 while a new search was running, which made a snapshot with a new query and the previous result set look complete (#4903)
|
||||
- It is now reset when a search starts and reaches 100 on the final result, so `progress` of 100 means the matches belong to the query reported next to them
|
||||
- Fixed adaptive height not reserving a line for the divider of an inline header or footer border, so the list came up one line short for each of them (#4904)
|
||||
- e.g. `seq 10 | fzf --height=~100% --list-border --header-lines=1 --header-lines-border=inline`
|
||||
- Fixed fzf erasing the line the prompt was on when it exits, which made the last line of the prompt flicker in fish, bash, and nushell (#4913)
|
||||
- Fixed fzf exiting with status 2 while waiting for a key, when `--listen` is used and 100+ signals interrupt the wait (#4917)
|
||||
- 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
|
||||
- fish
|
||||
- Fixed custom CTRL-T command not using the prefixed target directory in some cases (#4498) (@bitraid)
|
||||
- Optimized description alignment of completion items (#4910) (@bitraid)
|
||||
- nushell
|
||||
- Added key bindings for Helix editing modes, on nushell 0.115.0 or above (#4914) (@sim590)
|
||||
- Fixed CTRL-T inserting the selected paths unquoted
|
||||
- p4p3r (@P4P3R-HAK) reported the security vulnerability and suggested the fix
|
||||
|
||||
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
|
||||
|
||||
@@ -94,15 +94,19 @@ test: $(SOURCES)
|
||||
github.com/junegunn/fzf/src/tui \
|
||||
github.com/junegunn/fzf/src/util
|
||||
|
||||
# Run in a separate tmux server without user config, like CI (80x24)
|
||||
itest:
|
||||
ruby test/runner.rb
|
||||
@dir=$$(mktemp -d) && \
|
||||
env -u TMUX TMUX_TMPDIR=$$dir sh -c \
|
||||
'tmux -f /dev/null new-session -d && ruby test/runner.rb; s=$$?; tmux kill-server; exit $$s'; \
|
||||
s=$$?; rm -rf $$dir; exit $$s
|
||||
|
||||
# Actively fuzz the matcher fast paths against the general algorithm.
|
||||
# Go fuzzes one target at a time, so iterate. Override duration with
|
||||
# FUZZTIME (e.g. make fuzz FUZZTIME=5m).
|
||||
FUZZTIME ?= 30s
|
||||
fuzz:
|
||||
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two; do \
|
||||
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \
|
||||
echo "== $$t =="; \
|
||||
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
|
||||
done
|
||||
|
||||
+76
-20
@@ -1,6 +1,26 @@
|
||||
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
|
||||
------------
|
||||
|
||||
@@ -133,19 +153,35 @@ let g:fzf_action = {
|
||||
\ 'ctrl-v': 'vsplit' }
|
||||
|
||||
" Default fzf layout
|
||||
" - Popup window (center of the screen)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
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 current window)
|
||||
" 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)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
|
||||
|
||||
" - Popup window (anchored to the bottom of the current window)
|
||||
" - Vim 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 } }
|
||||
|
||||
" - down / up / left / right
|
||||
" - Vim split window: down / up / left / right
|
||||
let g:fzf_layout = { 'down': '40%' }
|
||||
|
||||
" - Window using a Vim command
|
||||
" - Vim window using a Vim command
|
||||
let g:fzf_layout = { 'window': 'enew' }
|
||||
let g:fzf_layout = { 'window': '-tabnew' }
|
||||
let g:fzf_layout = { 'window': '10new' }
|
||||
@@ -296,7 +332,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%`) |
|
||||
| `tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%`) |
|
||||
| `popup`/`tmux` | string | (Layout) `--popup` 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}`) |
|
||||
|
||||
@@ -408,10 +444,10 @@ Tips
|
||||
|
||||
### fzf inside terminal buffer
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```vim
|
||||
" Terminal colors for seoul256 color scheme
|
||||
@@ -442,7 +478,10 @@ else
|
||||
endif
|
||||
```
|
||||
|
||||
### Starting fzf in a popup window
|
||||
### 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`.
|
||||
|
||||
```vim
|
||||
" Required:
|
||||
@@ -458,16 +497,32 @@ endif
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
```
|
||||
|
||||
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.
|
||||
### 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.
|
||||
|
||||
```vim
|
||||
" 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%' }
|
||||
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%' }
|
||||
else
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
" Configure the Vim popup window in case not on the multiplexer
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
|
||||
endif
|
||||
```
|
||||
|
||||
@@ -475,7 +530,8 @@ 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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
|
||||
<kbd align="center">
|
||||
<br/>
|
||||
<a href="https://commitgoods.com/collections/fzf"><img src="https://junegunn.github.io/fzf/images/fzf-mugs.jpg" width="80%" alt="fzf merch"></a>
|
||||
<a href="https://commitgoods.com/collections/fzf?utm_source=github&utm_medium=referral"><img src="https://junegunn.github.io/fzf/images/fzf-mugs.jpg" width="80%" alt="fzf merch"></a>
|
||||
<br/>
|
||||
<br/>
|
||||
Show your love for fzf -- T-shirts, mugs, and stickers now available!
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://commitgoods.com/collections/fzf">commitgoods.com/collections/fzf</a>
|
||||
<a href="https://commitgoods.com/collections/fzf?utm_source=github&utm_medium=referral"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.shopify.com/s/files/1/0101/8752/files/commitgoods-badge-stacked-navy-dark.svg"><img src="https://cdn.shopify.com/s/files/1/0101/8752/files/commitgoods-badge-stacked-navy.svg" height="28" alt="Official merch on commitgoods.com"></picture></a>
|
||||
<br/>
|
||||
<br/>
|
||||
</kbd>
|
||||
@@ -841,6 +841,13 @@ Fuzzy completion in Nushell works via the
|
||||
[external completer](https://www.nushell.sh/cookbook/external_completers.html)
|
||||
mechanism. There are some differences compared to bash and zsh:
|
||||
|
||||
- Nushell 0.116.1 or newer is required. The completion runs on the line-editor
|
||||
thread so fzf can take the terminal; a previously configured external
|
||||
completer (e.g. Carapace) is therefore invoked inline when the `**` trigger
|
||||
is absent.
|
||||
- The script wraps whichever external completer is configured when it loads,
|
||||
so load it after any other external completer setup. The autoload setup
|
||||
described above runs after `config.nu`, so it already does this.
|
||||
- On Nushell >= 0.103.0, the external completer is no longer called for
|
||||
built-in commands (e.g. `cd`, `ls`). Fuzzy completion with `**<TAB>` only
|
||||
works for external commands.
|
||||
@@ -1143,7 +1150,7 @@ Copyright (c) 2013-2026 Junegunn Choi
|
||||
Goods
|
||||
-----
|
||||
|
||||
Grab fzf T-shirts, mugs, and stickers here: https://commitgoods.com/collections/fzf
|
||||
Grab fzf T-shirts, mugs, and stickers here: [commitgoods.com/collections/fzf](https://commitgoods.com/collections/fzf?utm_source=github&utm_medium=referral)
|
||||
|
||||
Sponsors :heart:
|
||||
----------------
|
||||
|
||||
+9
-5
@@ -7,7 +7,11 @@ triggered by a tag push.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Update version in the following files and commit on `master`:
|
||||
1. Sign in at https://developer.apple.com/account and accept any pending
|
||||
agreements (e.g. an updated Apple Developer Program License Agreement).
|
||||
Otherwise notarization fails.
|
||||
|
||||
2. Update version in the following files and commit on `master`:
|
||||
- `CHANGELOG.md`
|
||||
- `main.go`
|
||||
- `install`
|
||||
@@ -15,10 +19,10 @@ triggered by a tag push.
|
||||
- `man/man1/fzf.1`
|
||||
- `man/man1/fzf-tmux.1`
|
||||
|
||||
2. Verify file consistency, sign the tag, and push the tag.
|
||||
3. Verify file consistency, sign the tag, and push the tag.
|
||||
|
||||
```sh
|
||||
make tag VERSION=0.74.2
|
||||
make tag VERSION=0.74.5
|
||||
```
|
||||
|
||||
`make tag` runs `prerelease` first (checks that the version
|
||||
@@ -29,10 +33,10 @@ triggered by a tag push.
|
||||
old version, so `/master/install` keeps resolving against existing
|
||||
binaries during the publish window.
|
||||
|
||||
3. The workflow fires on the tag push and pauses on the `release`
|
||||
4. The workflow fires on the tag push and pauses on the `release`
|
||||
environment gate. Approve it in the Actions tab to release.
|
||||
|
||||
4. After the GitHub release is published, fast-forward `master`:
|
||||
5. After the GitHub release is published, fast-forward `master`:
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
|
||||
@@ -78,6 +78,7 @@ while [[ $# -gt 0 ]]; do
|
||||
fi
|
||||
if [[ ${#arg} -gt 2 ]]; then
|
||||
size="${arg:2}"
|
||||
[[ $size =~ ^[0-9%,]+$ || $size =~ ^[A-Z]$ ]] || continue
|
||||
else
|
||||
if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then
|
||||
size="$1"
|
||||
|
||||
+59
-20
@@ -14,7 +14,8 @@ 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 popup window |fzf-starting-fzf-in-a-popup-window|
|
||||
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|
|
||||
Hide statusline |fzf-hide-statusline|
|
||||
License |fzf-license|
|
||||
|
||||
@@ -161,19 +162,35 @@ Examples~
|
||||
\ 'ctrl-v': 'vsplit' }
|
||||
|
||||
" Default fzf layout
|
||||
" - Popup window (center of the screen)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
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 current window)
|
||||
" 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)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
|
||||
|
||||
" - Popup window (anchored to the bottom of the current window)
|
||||
" - Vim 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 } }
|
||||
|
||||
" - down / up / left / right
|
||||
" - Vim split window: down / up / left / right
|
||||
let g:fzf_layout = { 'down': '40%' }
|
||||
|
||||
" - Window using a Vim command
|
||||
" - Vim window using a Vim command
|
||||
let g:fzf_layout = { 'window': 'enew' }
|
||||
let g:fzf_layout = { 'window': '-tabnew' }
|
||||
let g:fzf_layout = { 'window': '10new' }
|
||||
@@ -310,7 +327,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%` )
|
||||
`tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%` )
|
||||
`popup` / `tmux` | string | (Layout) `--popup` 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}` )
|
||||
---------------------------+---------------+----------------------------------------------------------------------
|
||||
@@ -418,8 +435,8 @@ TIPS *fzf-tips*
|
||||
*fzf-inside-terminal-buffer*
|
||||
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -452,8 +469,11 @@ in Neovim.
|
||||
endif
|
||||
<
|
||||
|
||||
< Starting fzf in a popup window >____________________________________________~
|
||||
*fzf-starting-fzf-in-a-popup-window*
|
||||
< 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`.
|
||||
>
|
||||
" Required:
|
||||
" - width [float range [0 ~ 1]] or [integer range [8 ~ ]]
|
||||
@@ -467,15 +487,33 @@ in Neovim.
|
||||
" - 'rounded' / 'sharp' / 'horizontal' / 'vertical' / 'top' / 'bottom' / 'left' / 'right'
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
<
|
||||
Alternatively, you can make fzf open in a tmux popup window (requires tmux 3.2
|
||||
or above) by putting `--tmux` options in `tmux` key.
|
||||
|
||||
< 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.
|
||||
>
|
||||
" 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%' }
|
||||
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%' }
|
||||
else
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
" Configure the Vim popup window in case not on the multiplexer
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
|
||||
endif
|
||||
<
|
||||
|
||||
@@ -484,7 +522,8 @@ or above) by putting `--tmux` options in `tmux` key.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.74.2
|
||||
version=0.74.5
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.74.2"
|
||||
$version="0.74.5"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.2" "fzf\-tmux - open fzf in tmux split pane"
|
||||
.TH fzf\-tmux 1 "Oct 2026" "fzf 0.74.5" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+34
-6
@@ -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.2" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "Oct 2026" "fzf 0.74.5" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
@@ -408,6 +408,9 @@ Adaptive height has the following limitations:
|
||||
* Cannot be used with top/bottom margin and padding given in percent size
|
||||
.br
|
||||
* It will not find the right size when there are multi-line items
|
||||
.br
|
||||
* fzf cannot start until the input ends or exceeds the height, so a stream
|
||||
that stays below it will delay the first render
|
||||
|
||||
.TP
|
||||
.BI "\-\-min\-height=" "HEIGHT[+]"
|
||||
@@ -1906,7 +1909,10 @@ e.g.
|
||||
\fIfocus\fR
|
||||
.RS
|
||||
Triggered when the focus changes due to a vertical cursor movement or a search
|
||||
result update.
|
||||
result update. After a reload, the focused item is considered new unless
|
||||
\fB\-\-id\-nth\fR is set and the item has the same key as the previously
|
||||
focused item. Because of this, binding \fBreload\fR to this event can cause an
|
||||
infinite loop.
|
||||
|
||||
e.g.
|
||||
\fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}'
|
||||
@@ -2054,8 +2060,8 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
|
||||
\fBchange\-multi\fR (enable multi-select mode with no limit)
|
||||
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
|
||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
|
||||
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
|
||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
|
||||
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
|
||||
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
|
||||
@@ -2160,8 +2166,8 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
|
||||
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
|
||||
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
|
||||
\fBtransform\-nth(...)\fR (transform nth using an external command)
|
||||
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
|
||||
\fBtransform\-nth(...)\fR (transform nth using an external command; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
|
||||
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
|
||||
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
|
||||
@@ -2399,6 +2405,28 @@ e.g.
|
||||
# This is equivalent to toggle\-preview action
|
||||
fzf \-\-preview 'cat {}' \-\-bind 'ctrl\-/:change\-preview\-window(hidden|)'
|
||||
|
||||
.SS CHANGE NTH AND WITH\-NTH
|
||||
|
||||
\fBchange\-nth\fR and \fBchange\-with\-nth\fR actions change \fB\-\-nth\fR and
|
||||
\fB\-\-with\-nth\fR options. You can specify multiple values separated by '|'
|
||||
characters. Each time the action runs, fzf applies the value after the current
|
||||
one in the list, skipping the values equal to the current one.
|
||||
|
||||
\fBtransform\-nth\fR and \fBtransform\-with\-nth\fR actions (and their
|
||||
\fBbg\-\fR variants) take the output of the command as a single value. The
|
||||
following rules apply to all of these actions.
|
||||
|
||||
* An empty value restores the default given by the option
|
||||
* \fB..\fR selects all fields
|
||||
* A non\-empty invalid value is ignored
|
||||
|
||||
e.g.
|
||||
# All fields, then fields 1, 2, and 3
|
||||
fzf \-\-bind 'ctrl\-/:change\-nth(..|1|2|3)'
|
||||
|
||||
# An empty string in the list is interpreted as the default
|
||||
fzf \-\-nth 1 \-\-bind 'ctrl\-/:change\-nth(2|)'
|
||||
|
||||
.SH AUTHOR
|
||||
Junegunn Choi (\fIjunegunn.c@gmail.com\fR)
|
||||
|
||||
|
||||
+192
-22
@@ -24,7 +24,8 @@
|
||||
if exists('g:loaded_fzf')
|
||||
finish
|
||||
endif
|
||||
let g:loaded_fzf = 1
|
||||
" Bumped when the plugin gains behavior a caller may need to detect
|
||||
let g:loaded_fzf = 20260821
|
||||
|
||||
let s:is_win = has('win32') || has('win64')
|
||||
if s:is_win && &shellslash
|
||||
@@ -127,7 +128,7 @@ function! s:fzf_tempname()
|
||||
return s:fzf_call('tempname')
|
||||
endfunction
|
||||
|
||||
let s:layout_keys = ['window', 'tmux', 'up', 'down', 'left', 'right']
|
||||
let s:layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right']
|
||||
let s:fzf_go = s:base_dir.'/bin/fzf'
|
||||
let s:fzf_tmux = s:base_dir.'/bin/fzf-tmux'
|
||||
|
||||
@@ -139,6 +140,15 @@ 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.
|
||||
" Without a job, s:execute_tmux() blocks and freezes Vim. fzf also wants a
|
||||
" tmux window of at least 3x3, and Vim's pane is never larger than the
|
||||
" window, so asking Vim is free and survives a resize
|
||||
if (has('nvim') || has('job')) && &columns >= 3 && &lines >= 3
|
||||
\ && s:tmux_enabled() && get(s:, 'tmux_floating', 0)
|
||||
return { 'tmux': '90%,60%' }
|
||||
endif
|
||||
return s:popup_support()
|
||||
\ ? { 'window' : { 'width': 0.9, 'height': 0.6 } }
|
||||
\ : { 'down': '~40%' }
|
||||
@@ -164,6 +174,12 @@ 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 = {}
|
||||
@@ -255,8 +271,24 @@ 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') || !exists('$TMUX')
|
||||
if has('gui_running')
|
||||
return 0
|
||||
endif
|
||||
|
||||
if empty($TMUX) && empty($ZELLIJ)
|
||||
return 0
|
||||
endif
|
||||
|
||||
@@ -264,17 +296,42 @@ function! s:tmux_enabled()
|
||||
return s:tmux
|
||||
endif
|
||||
|
||||
let s:tmux = 0
|
||||
if !executable(s:fzf_tmux)
|
||||
if executable('fzf-tmux')
|
||||
let s:fzf_tmux = 'fzf-tmux'
|
||||
else
|
||||
return 0
|
||||
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
|
||||
endif
|
||||
|
||||
let output = system('tmux -V')
|
||||
let s:tmux = !v:shell_error && output >= 'tmux 1.7'
|
||||
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 also needs fzf 0.74.0 or
|
||||
" above. fzf opens a modal popup otherwise. The version here only skips the
|
||||
" probe for servers too old to answer it
|
||||
if s:compare_versions(ver, '3.3') >= 0
|
||||
let s:tmux = 1
|
||||
let s:tmux_floating = s:compare_versions(ver, '3.7') >= 0
|
||||
\ && s:tmux_floating_pane_support()
|
||||
\ && 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
|
||||
return s:tmux
|
||||
endfunction
|
||||
|
||||
@@ -422,7 +479,10 @@ function! fzf#wrap(...)
|
||||
if !exists('g:fzf_layout') && exists('g:fzf_height')
|
||||
let opts.down = g:fzf_height
|
||||
else
|
||||
let opts = extend(opts, s:validate_layout(get(g:, 'fzf_layout', s:default_layout())))
|
||||
" 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()))
|
||||
endif
|
||||
endif
|
||||
|
||||
@@ -502,6 +562,11 @@ 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
|
||||
@@ -539,16 +604,19 @@ 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
|
||||
\ && (has('gui_running') || s:is_win || s:present(dict, 'down', 'up', 'left', 'right', 'window'))
|
||||
let use_term = (has_nvim_term || has_vim8_term) && !s:need_cmd_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 --no-tmux'
|
||||
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
|
||||
elseif use_height
|
||||
let height = s:calc_size(&lines, dict.down, dict)
|
||||
let optstr .= ' --no-tmux --height='.height
|
||||
@@ -568,8 +636,12 @@ try
|
||||
return s:execute_term(dict, command, temps)
|
||||
endif
|
||||
|
||||
let lines = use_tmux ? s:execute_tmux(dict, command, temps)
|
||||
\ : s:execute(dict, command, use_height, temps)
|
||||
" 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)
|
||||
call s:callback(dict, lines)
|
||||
return lines
|
||||
finally
|
||||
@@ -586,6 +658,44 @@ 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 the running server can put fzf in a floating pane. fzf decides on
|
||||
" the server, not on the version the tmux client reports, so ask it the same
|
||||
" question rather than predicting the answer. See tmuxFloatingPaneInfo in
|
||||
" src/tmux.go, which also requires the tmux window to be at least 3x3; that
|
||||
" one changes with a resize, so s:default_layout() reads it from Vim instead
|
||||
" of caching it here
|
||||
function! s:tmux_floating_pane_support()
|
||||
" fzf does not use a floating pane when it was not started from a pane
|
||||
if empty($TMUX_PANE)
|
||||
return 0
|
||||
endif
|
||||
let out = system('tmux list-commands new-pane')
|
||||
" A server that does not know the command exits normally with no output
|
||||
return !v:shell_error && out =~# 'new-pane'
|
||||
endfunction
|
||||
|
||||
function! s:fzf_tmux(dict)
|
||||
let size = get(a:dict, 'tmux', '')
|
||||
if empty(size)
|
||||
@@ -597,10 +707,15 @@ function! s:fzf_tmux(dict)
|
||||
endfor
|
||||
endif
|
||||
|
||||
" Legacy fzf-tmux options
|
||||
if size =~ '-'
|
||||
" 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
|
||||
return printf('LINES=%d COLUMNS=%d %s %s %s --',
|
||||
\ &lines, &columns, fzf#shellescape(s:fzf_tmux), size, (has_key(a:dict, 'source') ? '' : '-'))
|
||||
\ &lines, &columns, fzf#shellescape(script), size, (has_key(a:dict, 'source') ? '' : '-'))
|
||||
end
|
||||
|
||||
" Using native --tmux option
|
||||
@@ -742,6 +857,42 @@ 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)
|
||||
@@ -750,11 +901,24 @@ 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)
|
||||
return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
|
||||
let lines = s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
|
||||
call s:callback(a:dict, lines)
|
||||
return lines
|
||||
endfunction
|
||||
|
||||
function! s:calc_size(max, val, dict)
|
||||
@@ -940,6 +1104,12 @@ function! s:execute_term(dict, command, temps) abort
|
||||
let fzf.temps.batchfile = s:fzf_tempname().'.bat'
|
||||
call s:writefile(s:wrap_cmds(a:command), fzf.temps.batchfile)
|
||||
let command = fzf.temps.batchfile
|
||||
elseif has('nvim') && &title && a:command =~ '[[:cntrl:]]'
|
||||
" Neovim puts the command in the buffer name, which can end up raw in
|
||||
" the terminal title (#4941)
|
||||
let fzf.temps.shellscript = s:fzf_tempname()
|
||||
call s:writefile([a:command], fzf.temps.shellscript)
|
||||
let command = 'sh '.fzf#shellescape(fzf.temps.shellscript)
|
||||
else
|
||||
let command = a:command
|
||||
endif
|
||||
|
||||
@@ -124,10 +124,8 @@ function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion s
|
||||
|
||||
# Determine the tabstop length for description alignment
|
||||
set -l -- max_columns (math $COLUMNS - 40)
|
||||
for i in $list[1..500]
|
||||
set -l -- item (string split -f 1 -- \t $i)
|
||||
and set -l -- len (string length -V -- $item)
|
||||
and test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
|
||||
for len in (string match -r -- '^[^\\t]*(?=\\t)' $list[1..500] | string length -V)
|
||||
test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
|
||||
and set -- tabstop $len
|
||||
end
|
||||
set -- tabstop (math $tabstop + 4)
|
||||
|
||||
+54
-21
@@ -8,6 +8,15 @@
|
||||
# An implementation of completion.nu
|
||||
# This loads FZF as a Nushell External Completer
|
||||
# https://www.nushell.sh/cookbook/external_completers.html
|
||||
#
|
||||
# Requires Nushell 0.116.0 or newer.
|
||||
#
|
||||
# On 0.116+, the registered completer closure must reach a command carrying the
|
||||
# `@interactive` attribute: interactivity is seen through the closure to that
|
||||
# command (a closure cannot carry the attribute itself), and that is what makes
|
||||
# the completion run on the REPL thread with the terminal, so fzf can take it.
|
||||
# A closure that does not reach such a command runs on a background worker
|
||||
# without the terminal, where fzf cannot run.
|
||||
|
||||
|
||||
# --- Default Environment Variables ---
|
||||
@@ -367,9 +376,9 @@ def _fzf_complete_kill [query: string] {
|
||||
|
||||
# --- Main FZF External Completer ---
|
||||
|
||||
# This function is registered with Nushell's external completion system.
|
||||
# It gets called when Tab is pressed.
|
||||
let fzf_external_completer = {|spans|
|
||||
# Main completion logic. It receives the token list of the command being
|
||||
# completed (the legacy `spans`). Called directly, so no attribute is needed.
|
||||
def __fzf_completer_logic [spans] {
|
||||
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||
|
||||
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
|
||||
@@ -446,6 +455,40 @@ let fzf_external_completer = {|spans|
|
||||
}
|
||||
}
|
||||
|
||||
# Nushell 0.116+ entry point. The registered closure must END with a call to
|
||||
# this command for the completion to run on the REPL thread with the terminal.
|
||||
#
|
||||
# It declares `place` (a named input) rather than the legacy `spans`, so the
|
||||
# completer never goes through the deprecated positional bridge. `$place.command`
|
||||
# is the token list of the command being completed, the same value the old
|
||||
# `spans` held (with a trailing "" when the cursor sits on an empty slot).
|
||||
@interactive
|
||||
def __fzf_external_completer [place] {
|
||||
let result = (__fzf_completer_logic $place.command)
|
||||
if $result != null {
|
||||
$result
|
||||
} else {
|
||||
# Fall back to the previously configured external completer, if any. It is
|
||||
# handed the new `place` input first. A completer that still declares the
|
||||
# legacy `spans` parameter errors on a record, so it is retried with the
|
||||
# token list, which is what the old positional bridge handed it.
|
||||
let previous = ($env.__fzf_previous_completer? | default null)
|
||||
let previous_result = if $previous != null {
|
||||
try {
|
||||
do $previous $place
|
||||
} catch {
|
||||
try { do $previous $place.command } catch { null }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
# An @interactive completer cannot decline with null: null reads as a
|
||||
# dismissed picker with an empty, final answer. Request the fallback
|
||||
# explicitly so file completion still runs when the trigger is absent.
|
||||
if $previous_result != null { $previous_result } else { {completions: [], fallback: true} }
|
||||
}
|
||||
}
|
||||
|
||||
# --- WRAPPER AND REGISTRATION ---
|
||||
|
||||
# Guard against re-sourcing: wrapping the completer multiple times would
|
||||
@@ -455,25 +498,15 @@ if ($env.__fzf_completer_registered? | default false) != true {
|
||||
# Get the currently configured external completer, if any exists
|
||||
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
|
||||
|
||||
# Define the new wrapper completer
|
||||
let fzf_wrapper_completer = {|spans|
|
||||
# 1. Try the FZF completer logic first
|
||||
let fzf_result = do $fzf_external_completer $spans
|
||||
# Stash it for `__fzf_external_completer`: a def cannot capture a local
|
||||
# variable, and the fallback to the previous completer happens inside it.
|
||||
$env.__fzf_previous_completer = $previous_external_completer
|
||||
|
||||
# 2. If FZF returned a result (a list, even an empty one), return it.
|
||||
# `null` means FZF didn't handle it because the trigger wasn't present.
|
||||
if $fzf_result != null {
|
||||
$fzf_result
|
||||
} else {
|
||||
# 3. FZF didn't handle it, so call the previous completer (if it exists).
|
||||
if $previous_external_completer != null {
|
||||
do $previous_external_completer $spans
|
||||
} else {
|
||||
# 4. No previous completer, and FZF didn't handle it. Return null.
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
# The registered closure must END with a call to an `@interactive` command so
|
||||
# that Nushell 0.116+ runs the completion on the REPL thread with the terminal
|
||||
# (it inspects the last pipeline element of the closure). Declaring `place`
|
||||
# instead of `spans` keeps it off the deprecated positional bridge.
|
||||
let fzf_wrapper_completer = {|place| __fzf_external_completer $place }
|
||||
|
||||
# Register the new wrapper completer
|
||||
# This ensures external completions are enabled and sets our wrapper.
|
||||
|
||||
@@ -215,7 +215,7 @@ _fzf_dir_completion() {
|
||||
|
||||
_fzf_feed_fifo() {
|
||||
command rm -f "$1"
|
||||
mkfifo "$1"
|
||||
command mkfifo "$1" || return
|
||||
cat <&0 > "$1" &|
|
||||
}
|
||||
|
||||
@@ -242,8 +242,9 @@ _fzf_complete() {
|
||||
rest=("$@")
|
||||
fi
|
||||
|
||||
local fifo lbuf matches post
|
||||
fifo="${TMPDIR:-/tmp}/fzf-complete-fifo-$$"
|
||||
local fifo fifo_dir lbuf matches post
|
||||
fifo_dir=$(command mktemp -d "${TMPDIR:-/tmp}/fzf-completion-XXXXXX") || return
|
||||
fifo="$fifo_dir/fifo"
|
||||
lbuf=${rest[0]}
|
||||
post="${funcstack[1]}_post"
|
||||
type $post > /dev/null 2>&1 || post=cat
|
||||
@@ -256,7 +257,7 @@ _fzf_complete() {
|
||||
if [ -n "$matches" ]; then
|
||||
LBUFFER="$lbuf$matches"
|
||||
fi
|
||||
command rm -f "$fifo"
|
||||
command rm -rf "$fifo_dir"
|
||||
}
|
||||
|
||||
# To use custom hostname lists, override __fzf_list_hosts.
|
||||
|
||||
+10
-3
@@ -104,7 +104,7 @@ function fzf_key_bindings
|
||||
# Store current token in $dir as root for the 'find' command
|
||||
function fzf-file-widget -d "List files and folders"
|
||||
set -l commandline (__fzf_parse_commandline)
|
||||
set -lx dir $commandline[1]
|
||||
set -l dir $commandline[1]
|
||||
set -l fzf_query $commandline[2]
|
||||
set -l prefix $commandline[3]
|
||||
|
||||
@@ -112,10 +112,17 @@ function fzf_key_bindings
|
||||
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
|
||||
"--multi $FZF_CTRL_T_OPTS --print0")
|
||||
|
||||
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
|
||||
set -lx FZF_DEFAULT_OPTS_FILE
|
||||
|
||||
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
|
||||
set -lx FZF_DEFAULT_COMMAND
|
||||
|
||||
if test -n "$FZF_CTRL_T_COMMAND"
|
||||
set -f result (eval $FZF_CTRL_T_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
|
||||
else
|
||||
set -f result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
|
||||
end
|
||||
|
||||
test -n "$result"
|
||||
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
|
||||
|
||||
commandline -f repaint
|
||||
|
||||
+34
-10
@@ -47,6 +47,20 @@ def __fzfcmd []: nothing -> list<string> {
|
||||
['fzf']
|
||||
}
|
||||
|
||||
# Keybinding modes to activate. The Helix modes only exist since Nushell
|
||||
# 0.115.0, so they are only included when running a version that supports
|
||||
# them (major > 0 covers a hypothetical 1.0+ where the minor resets).
|
||||
def __fzf_modes []: nothing -> list<string> {
|
||||
let v = version
|
||||
let major = ($v.major | into int)
|
||||
let minor = ($v.minor | into int)
|
||||
if ($major > 0) or ($minor >= 115) {
|
||||
['emacs', 'vi_normal', 'vi_insert', 'helix_normal', 'helix_select', 'helix_insert']
|
||||
} else {
|
||||
['emacs', 'vi_normal', 'vi_insert']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export-env {
|
||||
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
|
||||
@@ -55,11 +69,11 @@ export-env {
|
||||
}
|
||||
|
||||
# Directories
|
||||
const alt_c = {
|
||||
let alt_c = {
|
||||
name: fzf_dirs
|
||||
modifier: alt
|
||||
keycode: char_c
|
||||
mode: [emacs, vi_normal, vi_insert]
|
||||
mode: (__fzf_modes)
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
@@ -82,11 +96,11 @@ const alt_c = {
|
||||
}
|
||||
|
||||
# History
|
||||
const ctrl_r = {
|
||||
let ctrl_r = {
|
||||
name: fzf_history
|
||||
modifier: control
|
||||
keycode: char_r
|
||||
mode: [emacs, vi_insert, vi_normal]
|
||||
mode: (__fzf_modes)
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
@@ -115,16 +129,16 @@ const ctrl_r = {
|
||||
}
|
||||
|
||||
# Files
|
||||
const ctrl_t = {
|
||||
let ctrl_t = {
|
||||
name: fzf_files
|
||||
modifier: control
|
||||
keycode: char_t
|
||||
mode: [emacs, vi_normal, vi_insert]
|
||||
mode: (__fzf_modes)
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
cmd: "
|
||||
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
|
||||
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m --print0');
|
||||
let fzfcmd = (__fzfcmd);
|
||||
let fzf_args = ($fzfcmd | skip 1);
|
||||
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
|
||||
@@ -135,9 +149,19 @@ const ctrl_t = {
|
||||
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
|
||||
};
|
||||
let result = ($result | str replace --all (char newline) ' ' | str trim);
|
||||
commandline edit --append $result;
|
||||
commandline set-cursor --end
|
||||
# Serialize each path as a Nushell string literal, so that syntax
|
||||
# in a file name is not evaluated when the line is executed.
|
||||
let result = (
|
||||
$result
|
||||
| split row (char nul)
|
||||
| where {|path| $path != ''}
|
||||
| each {|path| $path | to nuon}
|
||||
| str join ' '
|
||||
);
|
||||
if ($result | is-not-empty) {
|
||||
commandline edit --append $'($result) ';
|
||||
commandline set-cursor --end
|
||||
}
|
||||
"
|
||||
}
|
||||
]
|
||||
|
||||
+88
-5
@@ -303,7 +303,9 @@ func bonusAt(input *util.Chars, idx int) int16 {
|
||||
}
|
||||
|
||||
func normalizeRune(r rune) rune {
|
||||
if r < 0x00C0 || r > 0xFF61 {
|
||||
// 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) {
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -345,10 +347,90 @@ 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() {
|
||||
return 0, input.Length()
|
||||
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)
|
||||
}
|
||||
|
||||
// Not possible
|
||||
@@ -466,8 +548,9 @@ 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
|
||||
disableSingle bool
|
||||
disableTwo bool
|
||||
disableRunePrefilter bool
|
||||
)
|
||||
|
||||
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
|
||||
|
||||
@@ -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 oracle.
|
||||
// general FuzzyMatchV2 algorithm, which serves as the reference.
|
||||
//
|
||||
// Two complementary strategies:
|
||||
// - Exhaustive: every string up to a fixed length over an alphabet that
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
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
|
||||
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "f",
|
||||
"漢a", "a漢", "한글/src", "🎉e"}
|
||||
|
||||
slab := util.MakeSlab(100*1024, 2048)
|
||||
engaged, bypassed := 0, 0
|
||||
|
||||
for trial := range 30000 {
|
||||
var sb strings.Builder
|
||||
for range 1 + rng.Intn(8) {
|
||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||
}
|
||||
chars := util.ToChars([]byte(sb.String()))
|
||||
if chars.IsBytes() {
|
||||
continue
|
||||
}
|
||||
if chars.MayFoldToAscii() {
|
||||
bypassed++
|
||||
} else {
|
||||
engaged++
|
||||
}
|
||||
pat := patterns[rng.Intn(len(patterns))]
|
||||
cs := rng.Intn(2) == 0
|
||||
if !cs {
|
||||
pat = strings.ToLower(pat)
|
||||
}
|
||||
pattern := []rune(pat)
|
||||
norm := rng.Intn(2) == 0
|
||||
fwd := rng.Intn(2) == 0
|
||||
wp := rng.Intn(2) == 0
|
||||
|
||||
disableRunePrefilter = true
|
||||
expR, expP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||
disableRunePrefilter = false
|
||||
gotR, gotP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||
|
||||
if gotR != expR || !samePos(gotP, expP) {
|
||||
t.Fatalf("trial %d: %q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
|
||||
trial, sb.String(), pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
|
||||
}
|
||||
}
|
||||
disableRunePrefilter = false
|
||||
t.Logf("prefilter engaged on %d items, bypassed on %d", engaged, bypassed)
|
||||
if engaged == 0 || bypassed == 0 {
|
||||
t.Fatalf("corpus did not exercise both paths (engaged=%d bypassed=%d)", engaged, bypassed)
|
||||
}
|
||||
|
||||
// Equivalence alone would still hold if the prefilter never filtered
|
||||
// anything, so confirm it both rejects and narrows.
|
||||
rejected, narrowed := 0, 0
|
||||
for range 5000 {
|
||||
var sb strings.Builder
|
||||
for range 1 + rng.Intn(8) {
|
||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||
}
|
||||
chars := util.ToChars([]byte(sb.String()))
|
||||
if chars.IsBytes() || chars.MayFoldToAscii() {
|
||||
continue
|
||||
}
|
||||
pattern := []rune(patterns[rng.Intn(len(patterns))])
|
||||
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
|
||||
switch {
|
||||
case lo < 0:
|
||||
rejected++
|
||||
case hi-lo < chars.Length():
|
||||
narrowed++
|
||||
}
|
||||
}
|
||||
t.Logf("prefilter rejected %d items, narrowed scope on %d", rejected, narrowed)
|
||||
if rejected == 0 {
|
||||
t.Fatal("prefilter never rejected an item, so equivalence proves nothing")
|
||||
}
|
||||
if narrowed == 0 {
|
||||
t.Fatal("prefilter never narrowed the scope")
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzyMatchV1 shares asciiFuzzyIndex, so it needs the same guarantee.
|
||||
func TestRunePrefilterEquivalenceV1(t *testing.T) {
|
||||
t.Cleanup(func() { disableRunePrefilter = false })
|
||||
rng := rand.New(rand.NewSource(5))
|
||||
parts := []string{"src", "conf", "a", "e", "/", "漢字", "한글", "мир", "café", "Å", "🎉"}
|
||||
slab := util.MakeSlab(100*1024, 2048)
|
||||
for trial := range 20000 {
|
||||
var sb strings.Builder
|
||||
for range 1 + rng.Intn(6) {
|
||||
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||
}
|
||||
chars := util.ToChars([]byte(sb.String()))
|
||||
if chars.IsBytes() {
|
||||
continue
|
||||
}
|
||||
pattern := []rune([]string{"a", "e", "conf", "src", "ae", "zz"}[rng.Intn(6)])
|
||||
cs, norm, fwd, wp := rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0
|
||||
|
||||
disableRunePrefilter = true
|
||||
expR, expP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||
disableRunePrefilter = false
|
||||
gotR, gotP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||
|
||||
if gotR != expR || !samePos(gotP, expP) {
|
||||
t.Fatalf("trial %d: %q pattern=%q\n prefilter on: %v %v\n prefilter off: %v %v",
|
||||
trial, sb.String(), string(pattern), gotR, gotP, expR, expP)
|
||||
}
|
||||
}
|
||||
disableRunePrefilter = false
|
||||
}
|
||||
|
||||
// normalizeRune skips the map when util.MayFoldToAscii rejects the rune. That
|
||||
// is only sound if every key of the map is flagged, since a flagged-false rune
|
||||
// is returned unchanged.
|
||||
func TestNormalizedKeysAreFlagged(t *testing.T) {
|
||||
for k := range normalized {
|
||||
if !util.MayFoldToAscii(k) {
|
||||
t.Errorf("normalized key U+%04X (%c) is not flagged by MayFoldToAscii", k, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guarding normalizeRune must not change what it returns, for any rune.
|
||||
func TestNormalizeRuneUnchangedByGuard(t *testing.T) {
|
||||
for r := rune(0); r <= unicode.MaxRune; r++ {
|
||||
if r >= 0xD800 && r <= 0xDFFF {
|
||||
continue
|
||||
}
|
||||
exp := r
|
||||
if r >= 0x00C0 && r <= 0xFF61 {
|
||||
if n := normalized[r]; n > 0 {
|
||||
exp = n
|
||||
}
|
||||
}
|
||||
if got := normalizeRune(r); got != exp {
|
||||
t.Fatalf("normalizeRune(U+%04X) = U+%04X, expected U+%04X", r, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step G lets non-ASCII pattern runes use the scan, but only when no other
|
||||
// rune can transform into them. Being uncased is not sufficient: U+00DF has no
|
||||
// simple uppercase yet U+1E9E lowercases onto it. This 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,6 @@ func Run(opts *Options) (int, error) {
|
||||
}
|
||||
|
||||
pattern := patternBuilder([]rune(*opts.Filter))
|
||||
matcher.sort = pattern.sortable
|
||||
|
||||
transformer := buildItemTransformer(opts)
|
||||
|
||||
|
||||
+7
-6
@@ -1678,15 +1678,15 @@ func init() {
|
||||
}
|
||||
|
||||
func maskActionContents(action string) string {
|
||||
masked := ""
|
||||
var sb strings.Builder
|
||||
Loop:
|
||||
for len(action) > 0 {
|
||||
loc := argActionRegexp.FindStringIndex(action)
|
||||
if loc == nil {
|
||||
masked += action
|
||||
sb.WriteString(action)
|
||||
break
|
||||
}
|
||||
masked += action[:loc[1]]
|
||||
sb.WriteString(action[:loc[1]])
|
||||
action = action[loc[1]:]
|
||||
if len(action) == 0 {
|
||||
break
|
||||
@@ -1695,7 +1695,7 @@ Loop:
|
||||
var ce string
|
||||
switch action[0] {
|
||||
case ':':
|
||||
masked += strings.Repeat(" ", len(action))
|
||||
sb.WriteString(strings.Repeat(" ", len(action)))
|
||||
break Loop
|
||||
case '(':
|
||||
ce = ")"
|
||||
@@ -1716,7 +1716,7 @@ Loop:
|
||||
// @$ or @+
|
||||
loc = regexp.MustCompile(fmt.Sprintf(`(?s)^%s.*?(%s[+,]|%s$)`, cs, ce, ce)).FindStringIndex(action)
|
||||
if loc == nil {
|
||||
masked += action
|
||||
sb.WriteString(action)
|
||||
break
|
||||
}
|
||||
// Keep + or , at the end
|
||||
@@ -1724,9 +1724,10 @@ Loop:
|
||||
if lastChar == '+' || lastChar == ',' {
|
||||
loc[1]--
|
||||
}
|
||||
masked += strings.Repeat(" ", loc[1])
|
||||
sb.WriteString(strings.Repeat(" ", loc[1]))
|
||||
action = action[loc[1]:]
|
||||
}
|
||||
masked := sb.String()
|
||||
masked = strings.ReplaceAll(masked, ",,,", string([]rune{',', escapedComma, ','}))
|
||||
masked = strings.ReplaceAll(masked, ",:,", string([]rune{',', escapedColon, ','}))
|
||||
masked = strings.ReplaceAll(masked, "::", string([]rune{escapedColon, ':'}))
|
||||
|
||||
+20
-22
@@ -275,22 +275,12 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
Sort: fastwalk.SortFilesFirst,
|
||||
}
|
||||
|
||||
// When following symlinks, precompute the absolute real paths of walker
|
||||
// roots so we can skip symlinks that point to an ancestor. fastwalk's
|
||||
// built-in loop detection (shouldTraverse) catches loops on the second
|
||||
// pass, but a single pass through a symlink like z: -> / already
|
||||
// traverses the entire root filesystem, causing severe resource
|
||||
// exhaustion. Skipping ancestor symlinks prevents this entirely.
|
||||
var absRoots []string
|
||||
if opts.follow {
|
||||
for _, root := range roots {
|
||||
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||
if abs, err := filepath.Abs(real); err == nil {
|
||||
absRoots = append(absRoots, filepath.Clean(abs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// When following symlinks, skip targets that point to an ancestor of the
|
||||
// current walker root. fastwalk's built-in loop detection (shouldTraverse)
|
||||
// catches loops on the second pass, but a single pass through a symlink
|
||||
// like z: -> / already traverses the entire root filesystem, causing
|
||||
// severe resource exhaustion. Skipping ancestor symlinks prevents this.
|
||||
var absRoot string
|
||||
|
||||
ignoresBase := []string{}
|
||||
ignoresFull := []string{}
|
||||
@@ -326,19 +316,17 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Skip symlinks whose target is an ancestor of (or equal to)
|
||||
// any walker root. Following such symlinks would traverse a
|
||||
// the current walker root. Following such symlinks would traverse a
|
||||
// superset of the tree we're already walking.
|
||||
if isDirSymlink && len(absRoots) > 0 {
|
||||
if isDirSymlink && absRoot != "" {
|
||||
if target, err := filepath.EvalSymlinks(path); err == nil {
|
||||
if abs, err := filepath.Abs(target); err == nil {
|
||||
abs = filepath.Clean(abs)
|
||||
if abs == string(os.PathSeparator) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
for _, absRoot := range absRoots {
|
||||
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,6 +365,16 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
}
|
||||
noerr := true
|
||||
for _, root := range roots {
|
||||
// Compare against this root only. Safe to update here because
|
||||
// fastwalk.Walk returns after all its workers finish.
|
||||
absRoot = ""
|
||||
if opts.follow {
|
||||
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||
if abs, err := filepath.Abs(real); err == nil {
|
||||
absRoot = filepath.Clean(abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
noerr = noerr && (fastwalk.Walk(&conf, root, fn) == nil)
|
||||
}
|
||||
return noerr
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -66,3 +70,70 @@ func TestReadFromCommand(t *testing.T) {
|
||||
t.Error("EvtReadFin should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func walkFiles(t *testing.T, roots ...string) []string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
files := []string{}
|
||||
reader := NewReader(func(data []byte) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
files = append(files, filepath.FromSlash(string(data)))
|
||||
return true
|
||||
}, util.NewEventBox(), nil, false, false)
|
||||
if !reader.readFiles(roots, walkerOpts{file: true, follow: true}, nil) {
|
||||
t.Fatal("file walk failed")
|
||||
}
|
||||
slices.Sort(files)
|
||||
return files
|
||||
}
|
||||
|
||||
func TestReadFilesSymlinkAcrossRoots(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "app")
|
||||
shared := filepath.Join(base, "shared")
|
||||
frontend := filepath.Join(shared, "frontend")
|
||||
os.MkdirAll(app, 0755)
|
||||
os.MkdirAll(frontend, 0755)
|
||||
os.WriteFile(filepath.Join(shared, "docs.txt"), nil, 0644)
|
||||
os.WriteFile(filepath.Join(frontend, "index.js"), nil, 0644)
|
||||
if err := os.Symlink(filepath.Join("..", "shared"), filepath.Join(app, "shared")); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
|
||||
viaApp := []string{
|
||||
filepath.Join(app, "shared", "docs.txt"),
|
||||
filepath.Join(app, "shared", "frontend", "index.js"),
|
||||
}
|
||||
both := append(slices.Clone(viaApp), filepath.Join(frontend, "index.js"))
|
||||
slices.Sort(both)
|
||||
for _, tc := range []struct {
|
||||
roots []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{app}, viaApp},
|
||||
{[]string{app, frontend}, both},
|
||||
{[]string{frontend, app}, both},
|
||||
} {
|
||||
if files := walkFiles(t, tc.roots...); !slices.Equal(files, tc.want) {
|
||||
t.Errorf("roots = %v, files = %v, want %v", tc.roots, files, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFilesSkipsAncestorSymlinks(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
project := filepath.Join(base, "project")
|
||||
os.Mkdir(project, 0755)
|
||||
file := filepath.Join(project, "file.txt")
|
||||
os.WriteFile(file, nil, 0644)
|
||||
os.WriteFile(filepath.Join(base, "outside.txt"), nil, 0644)
|
||||
for name, target := range map[string]string{"parent": "..", "self": "."} {
|
||||
if err := os.Symlink(target, filepath.Join(project, name)); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
}
|
||||
if files := walkFiles(t, project); !slices.Equal(files, []string{file}) {
|
||||
t.Errorf("files = %v, want [%s]", files, file)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
s := item.text.ToString()
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == '/' || s[i] == '\\' {
|
||||
for i := numChars - 1; i >= 0; i-- {
|
||||
if r := item.text.Get(i); r == '/' || r == '\\' {
|
||||
lastDelim = i
|
||||
break
|
||||
}
|
||||
|
||||
@@ -272,3 +272,28 @@ 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)
|
||||
}
|
||||
+178
-34
@@ -58,6 +58,7 @@ 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
|
||||
@@ -96,6 +97,9 @@ 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
|
||||
@@ -463,7 +467,7 @@ type Terminal struct {
|
||||
termSize tui.TermSize
|
||||
lastAction actionType
|
||||
lastKey string
|
||||
lastFocus int32
|
||||
lastFocus focusedItem
|
||||
areaLines int
|
||||
areaColumns int
|
||||
forcePreview bool
|
||||
@@ -488,6 +492,11 @@ type selectedItem struct {
|
||||
item *Item
|
||||
}
|
||||
|
||||
type focusedItem struct {
|
||||
major int // revision.major; changes on reload
|
||||
item *Item
|
||||
}
|
||||
|
||||
type byTimeOrder []selectedItem
|
||||
|
||||
func (a byTimeOrder) Len() int {
|
||||
@@ -1182,7 +1191,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
||||
initFunc: func() error { return renderer.Init() },
|
||||
executing: util.NewAtomicBool(false),
|
||||
lastAction: actStart,
|
||||
lastFocus: minItem.Index(),
|
||||
lastFocus: focusedItem{},
|
||||
lastActivity: time.Now(),
|
||||
numLinesCache: make(map[int32]numLinesCacheValue),
|
||||
// The initial load counts as a search in progress ('start:wait').
|
||||
@@ -1543,6 +1552,14 @@ func (t *Terminal) visibleInputLinesInList() int {
|
||||
|
||||
// Extra number of lines needed to display fzf
|
||||
func (t *Terminal) extraLines() int {
|
||||
// borderLines() reports zero for BorderInline, but addInline() still
|
||||
// reserves a divider line for it
|
||||
sectionLines := func(shape tui.BorderShape) int {
|
||||
if shape == tui.BorderInline {
|
||||
return 1
|
||||
}
|
||||
return borderLines(shape)
|
||||
}
|
||||
extra := 0
|
||||
if !t.inputless {
|
||||
extra++
|
||||
@@ -1558,16 +1575,16 @@ func (t *Terminal) extraLines() int {
|
||||
}
|
||||
if t.headerVisible {
|
||||
if t.hasHeaderWindow() {
|
||||
extra += borderLines(t.headerBorderShape)
|
||||
extra += sectionLines(t.headerBorderShape)
|
||||
}
|
||||
extra += len(t.header0)
|
||||
if w, shape := t.determineHeaderLinesShape(); w {
|
||||
extra += borderLines(shape)
|
||||
extra += sectionLines(shape)
|
||||
}
|
||||
extra += t.headerLines
|
||||
}
|
||||
if len(t.footer) > 0 {
|
||||
extra += borderLines(t.footerBorderShape)
|
||||
extra += sectionLines(t.footerBorderShape)
|
||||
extra += len(t.footer)
|
||||
}
|
||||
return extra
|
||||
@@ -2047,7 +2064,9 @@ func (t *Terminal) UpdateList(result MatchResult) {
|
||||
prevIndex = t.targetIndex
|
||||
t.targetIndex = minItem.Index()
|
||||
}
|
||||
t.progress = 100
|
||||
if result.final() {
|
||||
t.progress = 100
|
||||
}
|
||||
t.merger = merger
|
||||
t.resultMerger = merger
|
||||
t.passMerger = result.passMerger
|
||||
@@ -2276,6 +2295,14 @@ 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
|
||||
@@ -4849,6 +4876,38 @@ 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 {
|
||||
@@ -6012,6 +6071,26 @@ func (t *Terminal) currentIndex() int32 {
|
||||
return minItem.Index()
|
||||
}
|
||||
|
||||
func (t *Terminal) currentFocus() focusedItem {
|
||||
return focusedItem{t.revision.major, t.currentItem()}
|
||||
}
|
||||
|
||||
// Records the current focus and returns whether it differs from the last one.
|
||||
// Indexes restart after a reload, so items are compared by --id-nth key, if
|
||||
// set. Without it, any focused item after a reload is a new one.
|
||||
func (t *Terminal) updateFocus() bool {
|
||||
curr := t.currentFocus()
|
||||
last := t.lastFocus
|
||||
t.lastFocus = curr
|
||||
if curr.item == nil || last.item == nil {
|
||||
return curr.item != last.item
|
||||
}
|
||||
if curr.major == last.major {
|
||||
return curr.item.Index() != last.item.Index()
|
||||
}
|
||||
return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth)
|
||||
}
|
||||
|
||||
func (t *Terminal) trackKeyFor(item *Item, nth []Range) string {
|
||||
tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
|
||||
return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter)
|
||||
@@ -6225,6 +6304,43 @@ func (t *Terminal) startTimers(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextValue returns the first valid, non-current value after the current one
|
||||
// in a '|'-separated list, and the list rotated to end with it.
|
||||
// isValid can be nil.
|
||||
func nextValue(expr string, isCurrent func(string) bool, isValid func(string) bool) (string, string) {
|
||||
valid := func(value string) bool {
|
||||
return isValid == nil || isValid(value)
|
||||
}
|
||||
// An invalid value is never current, even if it resolves to the current one
|
||||
current := func(value string) bool {
|
||||
return valid(value) && isCurrent(value)
|
||||
}
|
||||
values := strings.Split(expr, "|")
|
||||
start := 0
|
||||
// Last value is the one applied by the previous call
|
||||
if !current(values[len(values)-1]) {
|
||||
for i, value := range values {
|
||||
if current(value) {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range values {
|
||||
pick := (start + i) % len(values)
|
||||
if value := values[pick]; valid(value) && !current(value) {
|
||||
return value, strings.Join(slices.Concat(values[pick+1:], values[:pick+1]), "|")
|
||||
}
|
||||
}
|
||||
// Nothing to pick
|
||||
for _, value := range values {
|
||||
if valid(value) {
|
||||
return value, expr
|
||||
}
|
||||
}
|
||||
return values[0], expr
|
||||
}
|
||||
|
||||
// Loop is called to start Terminal I/O
|
||||
func (t *Terminal) Loop() error {
|
||||
// prof := profile.Start(profile.ProfilePath("/tmp/"))
|
||||
@@ -6421,7 +6537,11 @@ func (t *Terminal) Loop() error {
|
||||
version--
|
||||
offset = 0
|
||||
}
|
||||
lines = append(lines, line)
|
||||
if split := t.splitOnIND(line); split != nil {
|
||||
lines = append(lines, split...)
|
||||
} else {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
|
||||
@@ -6573,8 +6693,7 @@ func (t *Terminal) Loop() error {
|
||||
info = true
|
||||
}
|
||||
focusChanged := focusedIndex != currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus {
|
||||
t.lastFocus = currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() {
|
||||
t.eventChan <- tui.Focus.AsEvent()
|
||||
if t.infoCommand != "" {
|
||||
info = true
|
||||
@@ -6959,9 +7078,11 @@ func (t *Terminal) Loop() error {
|
||||
}
|
||||
}
|
||||
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents {
|
||||
if newIndex := t.currentIndex(); newIndex != currentIndex {
|
||||
t.lastFocus = newIndex
|
||||
// Actions are ignored while blocked. The render loop will
|
||||
// report the focus after the reload.
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked {
|
||||
if t.currentIndex() != currentIndex {
|
||||
t.lastFocus = t.currentFocus()
|
||||
if t.infoCommand != "" {
|
||||
req(reqInfo)
|
||||
}
|
||||
@@ -7220,22 +7341,35 @@ func (t *Terminal) Loop() error {
|
||||
req(reqList, reqInfo)
|
||||
case actChangeNth, actTransformNth, actBgTransformNth:
|
||||
capture(true, func(expr string) {
|
||||
// Split nth expression
|
||||
tokens := strings.Split(expr, "|")
|
||||
if nth, err := splitNth(tokens[0]); err == nil || len(expr) == 0 {
|
||||
// Changed
|
||||
resolve := func(value string) ([]Range, bool) {
|
||||
// Empty value restores the default --nth
|
||||
if len(value) == 0 {
|
||||
return t.nth, true
|
||||
}
|
||||
nth, err := splitNth(value)
|
||||
return nth, err == nil
|
||||
}
|
||||
// Empty range list or a single '..' means all fields
|
||||
allFields := func(nth []Range) bool {
|
||||
return len(nth) == 0 || len(nth) == 1 && nth[0].IsFull()
|
||||
}
|
||||
isCurrent := func(nth []Range) bool {
|
||||
return compareRanges(t.nthCurrent, nth) || allFields(t.nthCurrent) && allFields(nth)
|
||||
}
|
||||
// Only change-nth cycles through '|'-separated values
|
||||
if a.t == actChangeNth {
|
||||
expr, a.a = nextValue(expr, func(value string) bool {
|
||||
nth, _ := resolve(value)
|
||||
return isCurrent(nth)
|
||||
}, func(value string) bool {
|
||||
_, ok := resolve(value)
|
||||
return ok
|
||||
})
|
||||
}
|
||||
if nth, ok := resolve(expr); ok && !isCurrent(nth) {
|
||||
newNth = &nth
|
||||
} else {
|
||||
// The default
|
||||
newNth = &t.nth
|
||||
}
|
||||
// Cycle
|
||||
if len(tokens) > 1 {
|
||||
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
||||
}
|
||||
if !compareRanges(t.nthCurrent, *newNth) {
|
||||
changed = true
|
||||
t.nthCurrent = *newNth
|
||||
t.nthCurrent = nth
|
||||
t.forceRerenderList()
|
||||
}
|
||||
})
|
||||
@@ -7244,15 +7378,22 @@ func (t *Terminal) Loop() error {
|
||||
break Action
|
||||
}
|
||||
capture(true, func(expr string) {
|
||||
tokens := strings.Split(expr, "|")
|
||||
withNthExpr := tokens[0]
|
||||
if len(tokens) > 1 {
|
||||
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
||||
resolve := func(value string) string {
|
||||
// Empty value restores the default --with-nth
|
||||
if len(value) == 0 {
|
||||
return t.withNthDefault
|
||||
}
|
||||
return value
|
||||
}
|
||||
// Empty value restores the default --with-nth
|
||||
if len(withNthExpr) == 0 {
|
||||
withNthExpr = t.withNthDefault
|
||||
if a.t == actChangeWithNth {
|
||||
expr, a.a = nextValue(expr, func(value string) bool {
|
||||
return resolve(value) == t.withNthExpr
|
||||
}, func(value string) bool {
|
||||
_, err := nthTransformer(resolve(value))
|
||||
return err == nil
|
||||
})
|
||||
}
|
||||
withNthExpr := resolve(expr)
|
||||
if withNthExpr != t.withNthExpr {
|
||||
if factory, err := nthTransformer(withNthExpr); err == nil {
|
||||
newWithNth = &withNthSpec{fn: factory(t.delimiter)}
|
||||
@@ -7369,7 +7510,9 @@ func (t *Terminal) Loop() error {
|
||||
case actReplaceQuery:
|
||||
current := t.currentItem()
|
||||
if current != nil {
|
||||
t.input = current.text.ToRunes()
|
||||
// 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.cx = len(t.input)
|
||||
}
|
||||
case actFatal:
|
||||
@@ -8539,6 +8682,7 @@ func (t *Terminal) Loop() error {
|
||||
reload := changed || newCommand != nil
|
||||
if reload {
|
||||
t.wait.searching = true
|
||||
t.progress = 0
|
||||
}
|
||||
var reloadRequest *searchRequest
|
||||
if reload {
|
||||
|
||||
+137
-8
@@ -29,6 +29,9 @@ 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{
|
||||
@@ -223,7 +226,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 'prompt'"
|
||||
templateToOutput[`{fzf:action} {fzf:prompt}`] = `backward-delete-char-eof {{.O}}prompt{{.O}}`
|
||||
|
||||
// IV. escaping placeholder
|
||||
templateToOutput[`\{}`] = `{}`
|
||||
@@ -251,10 +254,13 @@ func TestReplacePlaceholder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuoteEntry(t *testing.T) {
|
||||
type quotes struct{ E, O, SQ, DQ, BS string } // standalone escape, outer, single and double quotes, backslash
|
||||
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`}
|
||||
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`}
|
||||
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{`^`, `^"`, `'`, `\^"`, `\\`, `\`}
|
||||
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() {
|
||||
@@ -280,13 +286,13 @@ func TestQuoteEntry(t *testing.T) {
|
||||
`>`: `{{.O}}{{.E}}>{{.O}}`,
|
||||
`(`: `{{.O}}{{.E}}({{.O}}`,
|
||||
`)`: `{{.O}}{{.E}}){{.O}}`,
|
||||
`@`: `{{.O}}{{.E}}@{{.O}}`,
|
||||
`@`: `{{.O}}@{{.O}}`,
|
||||
`^`: `{{.O}}{{.E}}^{{.O}}`,
|
||||
`%`: `{{.O}}{{.E}}%{{.O}}`,
|
||||
`!`: `{{.O}}{{.E}}!{{.O}}`,
|
||||
`%USERPROFILE%`: `{{.O}}{{.E}}%USERPROFILE{{.E}}%{{.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}}`,
|
||||
`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}}`,
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
@@ -440,7 +446,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-preview-[0-9]{9}$`}},
|
||||
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-temp-[0-9]+$`}},
|
||||
}
|
||||
|
||||
// to force powershell-style escaping we temporarily set environment variable that fzf honors
|
||||
@@ -851,3 +857,126 @@ 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
current string
|
||||
want string
|
||||
rotated string
|
||||
}{
|
||||
{"a", "x", "a", "a"},
|
||||
{"a", "a", "a", "a"},
|
||||
{"a|b|c", "x", "a", "b|c|a"},
|
||||
{"a|b|c", "a", "b", "c|a|b"},
|
||||
{"a|b|c", "b", "c", "a|b|c"},
|
||||
{"a|b|c", "c", "a", "b|c|a"},
|
||||
{"a|a|b", "a", "b", "a|a|b"},
|
||||
{"a|b|b", "b", "a", "b|b|a"},
|
||||
{"a|a", "a", "a", "a|a"},
|
||||
{"a|b|", "", "a", "b||a"},
|
||||
{"|a", "", "a", "|a"},
|
||||
{"a|bad|b", "a", "b", "a|bad|b"},
|
||||
{"bad|a", "x", "a", "bad|a"},
|
||||
{"a|bad", "a", "a", "a|bad"},
|
||||
// An invalid value is never current
|
||||
{"a|bad|b", "bad", "a", "bad|b|a"},
|
||||
// Nothing to pick: the first valid value
|
||||
{"bad|a", "a", "a", "bad|a"},
|
||||
{"bad", "x", "bad", "bad"},
|
||||
// The last value is checked first
|
||||
{"b|a|c|a", "a", "b", "a|c|a|b"},
|
||||
}
|
||||
isValid := func(v string) bool { return v != "bad" }
|
||||
for _, tc := range tests {
|
||||
got, rotated := nextValue(tc.expr, func(v string) bool { return v == tc.current }, isValid)
|
||||
if got != tc.want || rotated != tc.rotated {
|
||||
t.Errorf("nextValue(%q) with current %q: got (%q, %q), want (%q, %q)",
|
||||
tc.expr, tc.current, got, rotated, tc.want, tc.rotated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextValueRepeated(t *testing.T) {
|
||||
// Presses with a rotated list reach every position, even with a repeated value
|
||||
expr := "1|2|1|3"
|
||||
current := "1"
|
||||
got := []string{}
|
||||
for range 6 {
|
||||
current, expr = nextValue(expr, func(v string) bool { return v == current }, nil)
|
||||
got = append(got, current)
|
||||
}
|
||||
if want := "2 1 3 1 2 1"; strings.Join(got, " ") != want {
|
||||
t.Errorf("got %q, want %q", strings.Join(got, " "), want)
|
||||
}
|
||||
}
|
||||
+23
-16
@@ -5,14 +5,19 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
)
|
||||
|
||||
// The flag group of new-pane usage includes -A (tmux 3.8 or above)
|
||||
var tmuxOverZoomFlag = regexp.MustCompile(`new-pane.*\[-[[:alpha:]]*A`)
|
||||
|
||||
// Returns the size of the current window if the tmux server supports
|
||||
// floating panes (tmux 3.7 or above)
|
||||
func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// floating panes (tmux 3.7 or above), and whether a floating pane can stay
|
||||
// above a zoomed pane
|
||||
func tmuxFloatingPaneInfo() (int, int, bool, bool) {
|
||||
// TMUX_PANE is not set when fzf is not started from within a pane
|
||||
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
|
||||
// pane there; a blocking run-shell suspends key processing for the
|
||||
@@ -25,7 +30,7 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// https://github.com/tmux/tmux/issues/5384
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
if target == "" {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
// A single invocation for both checks. Cannot rely on the exit status;
|
||||
// tmux versions before 3.7 exit normally with empty output for an
|
||||
@@ -33,17 +38,17 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
|
||||
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
|
||||
if err != nil || !strings.Contains(string(out), "new-pane") {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
var width, height int
|
||||
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
// The window is too small to fit a floating pane of the minimum size
|
||||
if width < 3 || height < 3 {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
return width, height, true
|
||||
return width, height, tmuxOverZoomFlag.Match(out), true
|
||||
}
|
||||
|
||||
// tmux ends a command at an argument ending in ';', so a trailing one is
|
||||
@@ -85,7 +90,7 @@ func tmuxDim(spec sizeSpec, window int) int {
|
||||
return max(3, min(dim, window))
|
||||
}
|
||||
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, overZoom bool, opts *Options) (int, error) {
|
||||
// Unlike display-popup, the size of a floating pane does not account for
|
||||
// the border around it, and the position is that of the content area. To
|
||||
// stay consistent with popups, treat the requested size as the total
|
||||
@@ -166,13 +171,15 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
|
||||
}
|
||||
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
|
||||
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
|
||||
// Unzoom the window first; creating a floating pane over a zoomed
|
||||
// window crashes the tmux server on 3.7b, and newer versions of
|
||||
// tmux unzoom the window anyway.
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
newPane := fmt.Sprintf(
|
||||
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
|
||||
newPane := "tmux new-pane -A"
|
||||
if !overZoom {
|
||||
// Unzoom the window first; creating a floating pane over a
|
||||
// zoomed window crashes the tmux server on 3.7b
|
||||
newPane = fmt.Sprintf("tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target))
|
||||
}
|
||||
newPane += fmt.Sprintf(" -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
|
||||
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
|
||||
|
||||
@@ -222,10 +229,10 @@ func runTmux(args []string, opts *Options) (int, error) {
|
||||
// popup is used instead so that the fzf-drawn border is the only border
|
||||
// shown; the native border of a tmux floating pane cannot be removed.
|
||||
if nativeBorder(opts) {
|
||||
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
|
||||
if windowWidth, windowHeight, overZoom, ok := tmuxFloatingPaneInfo(); ok {
|
||||
opts.Tmux.border = true
|
||||
argStr, dir := popupArgStr(args, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, overZoom, opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+311
-23
@@ -24,15 +24,44 @@ const (
|
||||
defaultEscDelay = 100
|
||||
escPollInterval = 5
|
||||
offsetPollTries = 10
|
||||
queryTimeout = 500 * time.Millisecond
|
||||
maxInputBuffer = 1024 * 1024
|
||||
maxSelectTries = 100
|
||||
escapeLookback = 256
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -146,6 +175,7 @@ type LightRenderer struct {
|
||||
width int
|
||||
height int
|
||||
yoffset int
|
||||
xoffset int
|
||||
tabstop int
|
||||
escDelay int
|
||||
fullscreen bool
|
||||
@@ -158,6 +188,10 @@ 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
|
||||
@@ -230,8 +264,13 @@ func (r *LightRenderer) Init() error {
|
||||
|
||||
if r.fullscreen {
|
||||
r.smcup()
|
||||
} else {
|
||||
y, x := r.findOffset()
|
||||
}
|
||||
|
||||
// Ask everything in one round trip, before the offset is needed.
|
||||
y, x, pasteWasSet := r.queryStartup()
|
||||
r.pasteWasSet = pasteWasSet
|
||||
|
||||
if !r.fullscreen {
|
||||
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
|
||||
@@ -239,6 +278,7 @@ func (r *LightRenderer) Init() error {
|
||||
// increased and we're left with unwanted extra new line.
|
||||
if x > 0 && r.clearOnExit {
|
||||
r.upOneLine = true
|
||||
r.xoffset = x
|
||||
r.makeSpace()
|
||||
}
|
||||
// We assume that --no-clear is used for repetitive relaunching of fzf.
|
||||
@@ -300,6 +340,150 @@ 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 }
|
||||
|
||||
// csiEnd returns the length of the CSI or SS3 sequence at the start of the
|
||||
// buffer, 0 if it has no final byte yet, or -1 if it is malformed.
|
||||
func csiEnd(buffer []byte) int {
|
||||
digits := true
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
b := buffer[i]
|
||||
// rxvt ends keys with $ after at least one digit, as in \e[7$ for
|
||||
// SHIFT-HOME and \e[23$ for SHIFT-F11. Elsewhere $ is an intermediate byte.
|
||||
if csiFinal(b) || b == '$' && digits && i > 2 {
|
||||
return i + 1
|
||||
}
|
||||
if !csiContinues(b) {
|
||||
return -1
|
||||
}
|
||||
digits = digits && b >= '0' && b <= '9'
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the
|
||||
// start of the buffer, or 0 while nothing has ended it. A terminator is
|
||||
// included, an ESC that introduces another sequence is not.
|
||||
func stringEnd(buffer []byte) int {
|
||||
if len(buffer) < 2 {
|
||||
return 0
|
||||
}
|
||||
// Every one of them ends with ST. BEL ends an OSC as well, because xterm has
|
||||
// always allowed it, but stopping at a BEL inside a DCS or APC payload would
|
||||
// frame only its first half and leave the rest to be typed into the query.
|
||||
bel := buffer[1] == ']'
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
switch buffer[i] {
|
||||
case '\a':
|
||||
if bel {
|
||||
return i + 1
|
||||
}
|
||||
case Esc.Byte():
|
||||
if i+1 == len(buffer) {
|
||||
return 0 // ST may still be arriving
|
||||
}
|
||||
if buffer[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
// Any other ESC ends the string and introduces a sequence of its
|
||||
// own, so frame only what precedes it and leave the ESC to be
|
||||
// parsed again. Scanning past it would swallow that sequence too.
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// rxvtKeyEnd reports whether the byte ends a key such as \e[7~ for Home.
|
||||
// rxvt marks modifiers with the final byte: $ for SHIFT, ^ for CTRL, @ for both.
|
||||
func rxvtKeyEnd(b byte) bool {
|
||||
return b == '~' || b == '$' || b == '^' || b == '@'
|
||||
}
|
||||
|
||||
// csiIntroducer reports whether the byte after ESC starts a sequence that is
|
||||
// framed by a final byte. The parser routes CSI and SS3 through the same
|
||||
// parameterized subcases, so both are framed by csiEnd.
|
||||
func csiIntroducer(b byte) bool {
|
||||
return b == '[' || b == 'O'
|
||||
}
|
||||
|
||||
// stringIntroducer reports whether the byte after ESC starts a string sequence.
|
||||
// Only the three that terminals reply with: OSC for colors, title and clipboard,
|
||||
// DCS for XTVERSION, DECRQSS and XTGETTCAP, APC for Kitty graphics. Nothing
|
||||
// sends SOS or PM.
|
||||
func stringIntroducer(b byte) bool {
|
||||
switch b {
|
||||
case 'P', ']', '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringReply reports whether a framed string sequence starts the way a
|
||||
// terminal's reply does. Its introducer is also ALT-], ALT-P or ALT-_, and typed
|
||||
// text after one, ended by CTRL-G or another key, frames like a reply when it
|
||||
// all arrives in one read.
|
||||
func stringReply(buffer []byte) bool {
|
||||
payload := buffer[2:]
|
||||
switch buffer[1] {
|
||||
case ']': // Ps ; ...
|
||||
i := 0
|
||||
for i < len(payload) && payload[i] >= '0' && payload[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
return i > 0 && i < len(payload) && payload[i] == ';'
|
||||
case 'P': // >| or !| for the version, [01]$r or [01]+r for settings
|
||||
if len(payload) < 2 {
|
||||
return false
|
||||
}
|
||||
if payload[1] == '|' {
|
||||
return payload[0] == '>' || payload[0] == '!'
|
||||
}
|
||||
return len(payload) >= 3 && (payload[0] == '0' || payload[0] == '1') &&
|
||||
(payload[1] == '$' || payload[1] == '+') && payload[2] == 'r'
|
||||
case '_': // G, then key=value
|
||||
return len(payload) >= 3 && payload[0] == 'G' &&
|
||||
(payload[1] >= 'a' && payload[1] <= 'z' || payload[1] >= 'A' && payload[1] <= 'Z') &&
|
||||
payload[2] == '='
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillArriving reports whether the CSI or SS3 sequence at the start of the
|
||||
// buffer may still be completed by more input. It must be the only one in the buffer:
|
||||
// whatever follows it, another sequence or a byte that ended it, means it is not
|
||||
// waiting for anything.
|
||||
func stillArriving(buffer []byte) bool {
|
||||
return bytes.IndexByte(buffer[1:], Esc.Byte()) < 0 && incompleteEscape(buffer)
|
||||
}
|
||||
|
||||
// incompleteEscape reports whether the buffer ends in a CSI or SS3 sequence that
|
||||
// has not been terminated yet. The read loop keeps waiting while it does, so a
|
||||
// fragment reaches the parser only once that wait has run out. A string sequence
|
||||
// is never waited for, because its introducer is also ALT-], ALT-P or ALT-_.
|
||||
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
|
||||
}
|
||||
// SS3 is judged like CSI: declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A. A malformed sequence is not waited for,
|
||||
// as nothing can complete it.
|
||||
return csiIntroducer(tail[start+1]) && csiEnd(tail[start:]) == 0
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
|
||||
return r.getBytesInternal(cancellable, r.buffer, false)
|
||||
}
|
||||
@@ -318,7 +502,11 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
|
||||
if c == Esc.Int() || nonblock {
|
||||
retries = r.escDelay / escPollInterval
|
||||
}
|
||||
buffer = append(buffer, byte(c))
|
||||
// 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))
|
||||
}
|
||||
|
||||
pc := c
|
||||
for {
|
||||
@@ -336,6 +524,13 @@ 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,
|
||||
@@ -393,17 +588,27 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
|
||||
return Event{CtrlSlash, 0, nil}
|
||||
case Esc.Byte():
|
||||
ev := r.escSequence(&sz)
|
||||
// Second chance
|
||||
if ev.Type == Invalid {
|
||||
r.buffer, result, err = r.getBytes(true)
|
||||
// Second chance, but only for an unfinished sequence. Re-reading
|
||||
// otherwise blocks until the next keystroke, holding back whatever
|
||||
// follows in the buffer.
|
||||
if ev.Type == Invalid && stillArriving(r.buffer) {
|
||||
waited := len(r.buffer)
|
||||
var buffer []byte
|
||||
buffer, result, err = r.getBytes(true)
|
||||
if err != nil {
|
||||
return Event{Fatal, 0, nil}
|
||||
}
|
||||
r.buffer = buffer
|
||||
if result == getCharCancelled {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
ev = r.escSequence(&sz)
|
||||
// This read has no time limit, so what it returned may be the next
|
||||
// keystroke rather than the rest of the sequence. Never drop it.
|
||||
if ev.Type == Invalid && sz > waited {
|
||||
sz = waited
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -435,7 +640,41 @@ func (r *LightRenderer) setCancel(f func()) {
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
// escSequence parses an escape sequence, then checks a CSI or SS3 result
|
||||
// against its frame. parseEscSequence can stop after a few bytes, and consuming
|
||||
// only those would leave the rest to be typed into the query. Complete
|
||||
// sequences it does not recognize are dropped there, unless they could be an
|
||||
// ALT key followed by typed text.
|
||||
func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
ev := r.parseEscSequence(sz)
|
||||
if len(r.buffer) < 3 || !csiIntroducer(r.buffer[1]) {
|
||||
return ev
|
||||
}
|
||||
// A frame missing its final byte may still be arriving
|
||||
end := csiEnd(r.buffer)
|
||||
if end <= 0 {
|
||||
return ev
|
||||
}
|
||||
// The parser can also set the size before checking the bytes and then give
|
||||
// up, counting typed input past the frame, as in \e[3;1u followed by ~
|
||||
if ev.Type == Invalid && *sz > end {
|
||||
*sz = end
|
||||
}
|
||||
// Four bytes or fewer could be ALT-[ or ALT-O and typed text, and rxvt sends
|
||||
// keys of that size fzf does not know, such as \e[3^ for CTRL-DELETE
|
||||
if end <= 4 {
|
||||
return ev
|
||||
}
|
||||
// A key matched on a prefix, such as Home for \e[70;5u, or a sequence given
|
||||
// up on partway
|
||||
if end > *sz {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if len(r.buffer) < 2 {
|
||||
return Event{Esc, 0, nil}
|
||||
}
|
||||
@@ -446,6 +685,12 @@ 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}
|
||||
@@ -510,6 +755,11 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if len(r.buffer) < 4 {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
// A byte that cannot continue the sequence, such as Enter after
|
||||
// ALT-[ and a digit, is a key of its own
|
||||
if csiEnd(r.buffer) < 0 {
|
||||
break
|
||||
}
|
||||
*sz = 4
|
||||
switch r.buffer[2] {
|
||||
case '2':
|
||||
@@ -542,7 +792,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{Delete, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -561,7 +811,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftDelete, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -584,12 +834,14 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '4':
|
||||
return Event{End, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
case '5':
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageUp, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -608,7 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageUp, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -634,7 +886,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageDown, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -653,7 +905,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageDown, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -676,15 +928,19 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '7':
|
||||
return Event{Home, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{Home, 0, nil}
|
||||
}
|
||||
case '8':
|
||||
return Event{End, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
case '1':
|
||||
switch r.buffer[3] {
|
||||
case '~':
|
||||
return Event{Home, 0, nil}
|
||||
case '1', '2', '3', '4', '5', '7', '8', '9':
|
||||
if len(r.buffer) == 5 && r.buffer[4] == '~' {
|
||||
if len(r.buffer) > 4 && r.buffer[4] == '~' {
|
||||
*sz = 5
|
||||
switch r.buffer[3] {
|
||||
case '1':
|
||||
@@ -891,6 +1147,24 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[1]
|
||||
// Nothing matched. A framed sequence is dropped whole: reading its
|
||||
// introducer as an ALT-key below would type the rest into the query.
|
||||
// Unterminated ones are left alone, as that is how ALT-[, ALT-O, ALT-],
|
||||
// ALT-P and ALT-_ arrive.
|
||||
if csiIntroducer(r.buffer[1]) {
|
||||
// ALT-[ or ALT-O and one or two typed characters can look like a short
|
||||
// sequence, so ask for at least two bytes before the final byte
|
||||
if end := csiEnd(r.buffer); end > 4 {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
} else if stringIntroducer(r.buffer[1]) {
|
||||
if end := stringEnd(r.buffer); end > 0 && stringReply(r.buffer) {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
}
|
||||
|
||||
rest := bytes.NewBuffer(r.buffer[1:])
|
||||
c, size, err := rest.ReadRune()
|
||||
if err == nil {
|
||||
@@ -907,11 +1181,13 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
rest := r.buffer[*sz:]
|
||||
end := bytes.IndexAny(rest, "mM")
|
||||
if end == -1 {
|
||||
// Not past the frame, where M or m can be a key typed later
|
||||
frame := csiEnd(r.buffer)
|
||||
if frame <= 0 || r.buffer[frame-1] != 'm' && r.buffer[frame-1] != 'M' {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
rest := r.buffer[*sz:]
|
||||
end := frame - 1 - *sz
|
||||
|
||||
elems := strings.SplitN(string(rest[:end]), ";", 3)
|
||||
if len(elems) != 3 {
|
||||
@@ -1019,7 +1295,16 @@ func (r *LightRenderer) disableMouse() {
|
||||
|
||||
func (r *LightRenderer) disableModes() {
|
||||
r.disableMouse()
|
||||
r.csi("?2004l")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LightRenderer) Resume(clear bool, sigcont bool) {
|
||||
@@ -1075,10 +1360,13 @@ func (r *LightRenderer) Close() {
|
||||
r.rmcup()
|
||||
} else {
|
||||
r.origin()
|
||||
// Erase our own area first, then step back onto the line the
|
||||
// prompt was on, so nothing on that line is touched
|
||||
r.csi("J")
|
||||
if r.upOneLine {
|
||||
r.csi("A")
|
||||
r.csi(fmt.Sprintf("%dG", r.xoffset+1))
|
||||
}
|
||||
r.csi("J")
|
||||
}
|
||||
} else if !r.fullscreen {
|
||||
r.stderr("\x1b8") // DECRC: restore cursor position
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
// An unrecognized escape sequence must be consumed whole, CSI and string
|
||||
// sequences alike. Consuming only part of one leaves the rest to be read as
|
||||
// input and typed into the query.
|
||||
func TestUnknownEscapeSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Key encodings fzf does not implement
|
||||
{"\x1b[97;5u", Invalid, 7},
|
||||
{"\x1b[70;5u", Invalid, 7}, // not Home, which its prefix \e[7 matches
|
||||
{"\x1b[42;5u", Invalid, 7}, // nor End
|
||||
{"\x1b[4;5~", Invalid, 6},
|
||||
{"\x1b[127;5u", Invalid, 8},
|
||||
{"\x1b[27;5;127~", Invalid, 11},
|
||||
{"\x1b[57441;1u", Invalid, 10},
|
||||
{"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped
|
||||
|
||||
// Replies to queries fzf did not send, or sent and stopped waiting for
|
||||
{"\x1b[?1;2c", Invalid, 7},
|
||||
{"\x1b[>0;95;0c", Invalid, 10},
|
||||
|
||||
// Mouse report arriving while mouse input is off
|
||||
{"\x1b[<0;1;1M", Invalid, 9},
|
||||
|
||||
// String sequences
|
||||
{"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply
|
||||
{"\x1b]0;a title\a", Invalid, 12}, // BEL terminated
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply
|
||||
{"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply
|
||||
{"\x1bP1$r\a\x1b\\", Invalid, 8}, // BEL is payload in a DCS
|
||||
{"\x1b]11;x\x1bX\x1b\\", Invalid, 6}, // ESC ends it, framing "\e]11;x"
|
||||
|
||||
// SS3 is framed like CSI, since the parser routes both the same way
|
||||
{"\x1bO9;5u", Invalid, 6},
|
||||
{"\x1bO1;5X", Invalid, 6}, // unknown final byte
|
||||
{"\x1bO1;5A", CtrlUp, 6}, // recognized, unchanged
|
||||
{"\x1bOP", F1, 3},
|
||||
|
||||
// Left alone: this is how ALT-[, ALT-O, ALT-], ALT-P and ALT-_ arrive
|
||||
{"\x1b[a", Alt, 2},
|
||||
{"\x1b[9A", Alt, 2}, // ALT-[ and two characters can look like a CSI
|
||||
{"\x1b[1m", Alt, 2},
|
||||
{"\x1b[ x", Alt, 2},
|
||||
{"\x1bOx", Alt, 2},
|
||||
{"\x1bO9A", Alt, 2},
|
||||
|
||||
// rxvt keys fzf does not know: dropped, not typed
|
||||
{"\x1b[3^", Invalid, 4}, // CTRL-DELETE
|
||||
{"\x1b[5^", Invalid, 4}, // CTRL-PAGEUP
|
||||
{"\x1b[3@", Invalid, 4}, // CTRL-SHIFT-DELETE
|
||||
{"\x1b[11^", Invalid, 5},
|
||||
|
||||
// rxvt ends keys with $ after digits, so what follows is typed
|
||||
{"\x1b[7$x", Home, 4}, // SHIFT-HOME, then x
|
||||
{"\x1b[7$1x", Home, 4},
|
||||
{"\x1b[3$x", Invalid, 4},
|
||||
{"\x1b[3$1x", Invalid, 4},
|
||||
{"\x1b[23$x", Invalid, 5}, // SHIFT-F11, then x
|
||||
{"\x1b[24$1x", Invalid, 5},
|
||||
|
||||
// A parse that gives up past the frame consumes only the frame, so the
|
||||
// typed ~ after these stays in the buffer
|
||||
{"\x1b[3;1u~", Invalid, 6},
|
||||
{"\x1b[5;1u~", Invalid, 6},
|
||||
{"\x1b[2$~", Invalid, 4}, // rxvt SHIFT-INSERT
|
||||
{"\x1b[2^~", Invalid, 4}, // rxvt CTRL-INSERT
|
||||
{"\x1b[2A~", Invalid, 4},
|
||||
|
||||
// Elsewhere $ and other intermediate bytes do not end a sequence
|
||||
{"\x1b[4;0;10;20;0&w", Invalid, 15}, // not End, which \e[4 matches
|
||||
{"\x1b[4;2$y", Invalid, 7},
|
||||
{"\x1b[2 q", Invalid, 5},
|
||||
{"\x1b]abc", Alt, 2},
|
||||
{"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived
|
||||
{"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key
|
||||
{"\x1b]\x1b[A", Alt, 2},
|
||||
{"\x1bP\x1bP", Alt, 2},
|
||||
{"\x1b_\x1b_", Alt, 2},
|
||||
{"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC
|
||||
{"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort
|
||||
|
||||
// Typed text after the introducer is not a reply, even when a
|
||||
// terminator follows in the same read
|
||||
{"\x1b]a\a", Alt, 2},
|
||||
{"\x1b]1\a", Alt, 2},
|
||||
{"\x1b]a\x1b[A", Alt, 2},
|
||||
{"\x1bPx\x1b[A", Alt, 2},
|
||||
{"\x1b_ab\x1bb", Alt, 2},
|
||||
|
||||
// SOS and PM are not framed
|
||||
{"\x1bXsos\x1b\\", Alt, 2},
|
||||
{"\x1b^status\x1b\\", Alt, 2},
|
||||
|
||||
// Left alone: no final byte yet, so the sequence may still be arriving
|
||||
{"\x1b[", Invalid, 2},
|
||||
|
||||
// Recognized sequences keep their existing parsing
|
||||
{"\x1b[1;5A", CtrlUp, 6},
|
||||
{"\x1b[3;5~", CtrlDelete, 6},
|
||||
{"\x1b[2~", Insert, 4},
|
||||
{"\x1b[200~", BracketedPasteBegin, 6},
|
||||
{"\x1b[Z", ShiftTab, 3},
|
||||
{"\x1bOA", Up, 3},
|
||||
{"\x1b[12;34R", Invalid, 8},
|
||||
{"\x1b[?2004;2$y", Invalid, 11},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence)}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
// Terminated
|
||||
{"\x1b]0;t\a", 6},
|
||||
{"\x1b]0;t\x1b\\", 7},
|
||||
{"\x1b_G\x1b\\", 5},
|
||||
{"\x1bP1$r\a\x1b\\", 8}, // BEL is payload in a DCS, ST ends it
|
||||
|
||||
// BEL terminates an OSC only
|
||||
{"\x1bP\a", 0},
|
||||
{"\x1b_Gi=1\a", 0},
|
||||
|
||||
// An ESC ends the string and introduces a sequence of its own
|
||||
{"\x1b]foo\x1bX\x1b\\", 5},
|
||||
{"\x1b]foo\x1bP", 5},
|
||||
{"\x1b]\x1b]", 2},
|
||||
|
||||
// Nothing has ended it yet
|
||||
{"\x1b]0;t", 0},
|
||||
{"\x1b]0;t\x1b", 0}, // ST half arrived
|
||||
{"\x1b]foo\x1b", 0},
|
||||
{"\x1b]", 0},
|
||||
{"\x1b", 0}, // shorter than an introducer
|
||||
|
||||
} {
|
||||
if got := stringEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped sequence followed by ALT-[ is not waiting for anything, so the
|
||||
// bytes in between must not be held back until the next keystroke
|
||||
func TestStillArriving(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b[97;5u\a\x1b[", false},
|
||||
{"\x1b[97;5u\x1b[", false},
|
||||
{"\x1b[3;\a\x1b[", false}, // the BEL ended it, the trailing \e[ is another one
|
||||
{"\x1b[1;\a", false}, // the BEL has already ended it
|
||||
{"\x1b[1;", true},
|
||||
{"\x1b[", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1b[<0;1", true},
|
||||
} {
|
||||
if got := stillArriving([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stillArriving(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringReply(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b]11;rgb:1/2/3\a", true},
|
||||
{"\x1b]0;title\a", true},
|
||||
{"\x1b]52;c;YWJj\x1b\\", true},
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", true},
|
||||
{"\x1bP!|0\x1b\\", true},
|
||||
{"\x1bP1$r0m\x1b\\", true},
|
||||
{"\x1bP0+r\x1b\\", true},
|
||||
{"\x1b_Gi=1;OK\x1b\\", true},
|
||||
|
||||
// What typed text after ALT-], ALT-P or ALT-_ looks like
|
||||
{"\x1b]\a", false},
|
||||
{"\x1b]a\a", false},
|
||||
{"\x1b]1\a", false},
|
||||
{"\x1b];\a", false},
|
||||
{"\x1bPx\x1b\\", false},
|
||||
{"\x1bP1\x1b\\", false},
|
||||
{"\x1b_ab\x1b\\", false},
|
||||
{"\x1b_G\x1b\\", false},
|
||||
} {
|
||||
if got := stringReply([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringReply(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsiEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
{"\x1b[97;5u", 7},
|
||||
{"\x1b[A", 3},
|
||||
{"\x1b[<0;1;1M", 9},
|
||||
{"\x1b[?2004;2$y", 11},
|
||||
{"\x1b[7$x", 4}, // rxvt ends keys with $ after digits
|
||||
{"\x1b[23$", 5},
|
||||
{"\x1b[4;2$y", 7}, // but $ is intermediate elsewhere
|
||||
{"\x1b[97;5", 0}, // no final byte
|
||||
{"\x1b[", 0}, // no final byte
|
||||
{"\x1b[1\x01A", -1}, // malformed, do not frame it
|
||||
{"\x1b[\a", -1},
|
||||
} {
|
||||
if got := csiEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that follows a sequence in the same read must not be consumed with it
|
||||
func TestKeyAfterSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
mouse bool
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Recognized when more input follows, as from a paste or tmux send-keys
|
||||
{"\x1b[15~x", false, F5, 5},
|
||||
{"\x1b[3;5~x", false, CtrlDelete, 6},
|
||||
{"\x1b[5;13~x", false, CtrlAltPageUp, 7},
|
||||
{"\x1b[6;2~\x1b[A", false, ShiftPageDown, 6},
|
||||
|
||||
// ALT-[ or ALT-O and a digit, then a key that cannot continue a sequence
|
||||
{"\x1b[2\r", false, Alt, 2},
|
||||
{"\x1b[12\x03", false, Alt, 2},
|
||||
{"\x1bO2\a", false, Alt, 2},
|
||||
{"\x1b[4x", false, Alt, 2}, // not End
|
||||
{"\x1b[7\r", false, Alt, 2}, // not Home
|
||||
{"\x1b[7~x", false, Home, 4},
|
||||
{"\x1b[8^x", false, End, 4}, // rxvt CTRL-END
|
||||
|
||||
// A mouse report ends at its own final byte
|
||||
{"\x1b[<0;5;3Mx", true, Mouse, 9},
|
||||
{"\x1b[<0;1;\x1b[AM", true, Invalid, 3},
|
||||
{"\x1b[<0;1;1xM", true, Invalid, 9},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence), mouse: c.mouse}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A string sequence is not waited for. Its introducer is also ALT-], ALT-P or
|
||||
// ALT-_, and holding the read open on those both delayed the key and let typed
|
||||
// bytes accumulate into something that looked like a sequence.
|
||||
func TestIncompleteStringEscape(t *testing.T) {
|
||||
for _, buffer := range []string{
|
||||
"\x1b]11;rgb:",
|
||||
"\x1bP>|kitty",
|
||||
"\x1b_Gi=1",
|
||||
"\x1b]0;t\x1b",
|
||||
"\x1b]0;title\a",
|
||||
"ab\x1b]11;rgb:",
|
||||
} {
|
||||
if incompleteEscape([]byte(buffer)) {
|
||||
t.Errorf("incompleteEscape(%q) = true, want false", buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SS3 reaches the same parameterized subcases as CSI in the parser, so it has to
|
||||
// be judged the same way here. Declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A and type its tail into the query.
|
||||
func TestIncompleteSS3(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1bO", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1bO1;", true},
|
||||
{"\x1bO1;5", true},
|
||||
{"\x1bOA", false},
|
||||
{"\x1bOP", false},
|
||||
{"\x1bO1;5A", false},
|
||||
{"\x1b[23$", false}, // rxvt SHIFT-F11 is complete
|
||||
{"\x1b[7$", false},
|
||||
{"\x1b[4;2$", true}, // $ after a ; is intermediate
|
||||
} {
|
||||
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//go:build !windows
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The second-chance read has no time limit, so a key typed long after an
|
||||
// unfinished sequence must not be dropped with it.
|
||||
func TestLateKeyAfterUnfinishedSequence(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close(); inW.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("x")
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("y") // ends the wait if x was dropped
|
||||
}()
|
||||
|
||||
r := &LightRenderer{ttyin: inR, escDelay: defaultEscDelay}
|
||||
for range 3 {
|
||||
if ev := r.GetChar(false); ev.Type == Rune {
|
||||
if ev.Char != 'x' {
|
||||
t.Errorf("got %q, want 'x'", ev.Char)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the late key was dropped")
|
||||
}
|
||||
|
||||
// A read error during the second-chance read must end fzf cleanly, not panic.
|
||||
func TestSecondChanceReadError(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.Close()
|
||||
}()
|
||||
|
||||
out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &LightRenderer{ttyin: inR, ttyout: out, escDelay: defaultEscDelay, origState: &term.State{}}
|
||||
if ev := r.GetChar(false); ev.Type != Fatal {
|
||||
t.Errorf("got %s, want Fatal", ev.Type.String())
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;1R", "!Invalid")
|
||||
assertEscSequence("\x1b[", "!Invalid")
|
||||
assertEscSequence("\x1b[1", "!Invalid")
|
||||
assertEscSequence("\x1b[3;3~1", "!Invalid")
|
||||
assertEscSequence("\x1b[13", "!Invalid")
|
||||
assertEscSequence("\x1b[1;3", "!Invalid")
|
||||
assertEscSequence("\x1b[1;10", "!Invalid")
|
||||
@@ -200,6 +199,7 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;3H", "alt-home")
|
||||
assertEscSequence("\x1b[1;3F", "alt-end")
|
||||
assertEscSequence("\x1b[3;3~", "alt-delete")
|
||||
assertEscSequence("\x1b[3;3~1", "alt-delete") // then 1
|
||||
assertEscSequence("\x1b[5;3~", "alt-page-up")
|
||||
assertEscSequence("\x1b[6;3~", "alt-page-down")
|
||||
|
||||
@@ -434,6 +434,7 @@ 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)
|
||||
|
||||
+113
-16
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/junegunn/fzf/src/util"
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -93,25 +94,120 @@ func (r *LightRenderer) updateTerminalSize() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LightRenderer) findOffset() (row int, col int) {
|
||||
r.csi("6n")
|
||||
r.flush()
|
||||
var err error
|
||||
bytes := []byte{}
|
||||
for tries := range offsetPollTries {
|
||||
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
|
||||
// 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 {
|
||||
return -1, -1
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
return -1, -1
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
||||
@@ -144,7 +240,7 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
|
||||
}()
|
||||
|
||||
cancelFd := int(rpipe.Fd())
|
||||
for range maxSelectTries {
|
||||
for {
|
||||
var rfds unix.FdSet
|
||||
limit := len(rfds.Bits) * unix.NFDBITS
|
||||
if fd >= limit || cancelFd >= limit {
|
||||
@@ -155,6 +251,8 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
|
||||
rfds.Set(cancelFd)
|
||||
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
|
||||
if err != nil {
|
||||
// An interrupted wait is not a failed read, so it must not count
|
||||
// against anything. Retry until the fd is ready or the wait fails.
|
||||
if err == syscall.EINTR {
|
||||
continue
|
||||
}
|
||||
@@ -169,7 +267,6 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
|
||||
return getter()
|
||||
}
|
||||
}
|
||||
return 0, getCharError
|
||||
}
|
||||
|
||||
func (r *LightRenderer) Size() TermSize {
|
||||
|
||||
@@ -151,6 +151,13 @@ 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
|
||||
|
||||
+142
-9
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
@@ -13,9 +14,17 @@ const (
|
||||
overflow32 uint32 = 0x80808080
|
||||
)
|
||||
|
||||
const (
|
||||
flagInBytes uint8 = 1 << iota
|
||||
flagMayFold
|
||||
)
|
||||
|
||||
type Chars struct {
|
||||
slice []byte // or []rune
|
||||
inBytes bool
|
||||
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
|
||||
trimLengthKnown bool
|
||||
trimLength uint16
|
||||
|
||||
@@ -24,6 +33,59 @@ 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 {
|
||||
@@ -44,31 +106,94 @@ 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, inBytes: inBytes}
|
||||
return Chars{slice: bytes, flags: flagInBytes}
|
||||
}
|
||||
|
||||
runes := make([]rune, bytesUntil, len(bytes))
|
||||
runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:]))
|
||||
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)
|
||||
return runesToChars(runes, mayFold)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false}
|
||||
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}
|
||||
}
|
||||
|
||||
func (chars *Chars) IsBytes() bool {
|
||||
return chars.inBytes
|
||||
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()
|
||||
}
|
||||
|
||||
func (chars *Chars) Bytes() []byte {
|
||||
@@ -105,7 +230,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) {
|
||||
}
|
||||
|
||||
func (chars *Chars) optionalRunes() []rune {
|
||||
if chars.inBytes {
|
||||
if chars.IsBytes() {
|
||||
return nil
|
||||
}
|
||||
return *(*[]rune)(unsafe.Pointer(&chars.slice))
|
||||
@@ -127,7 +252,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, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.inBytes, chars.trimLengthKnown, chars.trimLength, chars.Index)
|
||||
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)
|
||||
}
|
||||
|
||||
// TrimLength returns the length after trimming leading and trailing whitespaces
|
||||
@@ -218,6 +343,8 @@ 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
|
||||
@@ -247,6 +374,12 @@ 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) {
|
||||
|
||||
+162
-20
@@ -2,19 +2,106 @@ 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.inBytes || chars.ToString() != "foobar" || !chars.inBytes {
|
||||
if !chars.IsBytes() || chars.ToString() != "foobar" {
|
||||
t.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsLength(t *testing.T) {
|
||||
chars := ToChars([]byte("\tabc한글 "))
|
||||
if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 {
|
||||
if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 {
|
||||
t.Error()
|
||||
}
|
||||
}
|
||||
@@ -107,25 +194,80 @@ 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
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if string(lines4[0]) != "hello wo" {
|
||||
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
|
||||
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)
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -105,16 +105,18 @@ func RepeatToFill(str string, length int, limit int) string {
|
||||
rest := limit % length
|
||||
output := strings.Repeat(str, times)
|
||||
if rest > 0 {
|
||||
for _, r := range str {
|
||||
rest -= uniseg.StringWidth(string(r))
|
||||
if rest < 0 {
|
||||
break
|
||||
}
|
||||
output += string(r)
|
||||
if rest == 0 {
|
||||
// Iterate over grapheme clusters so that we don't cut a cluster in half
|
||||
end := 0
|
||||
graphemes := uniseg.NewGraphemes(str)
|
||||
for rest > 0 && graphemes.Next() {
|
||||
width := graphemes.Width()
|
||||
if width > rest {
|
||||
break
|
||||
}
|
||||
rest -= width
|
||||
_, end = graphemes.Positions()
|
||||
}
|
||||
output += str[:end]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -109,6 +109,27 @@ func TestRepeatToFill(t *testing.T) {
|
||||
if RepeatToFill("abcde", 10, 42) != strings.Repeat("abcde", 4)+"abcde"[:2] {
|
||||
t.Error("Expected:", strings.Repeat("abcde", 4)+"abcde"[:2])
|
||||
}
|
||||
|
||||
// Should not cut a grapheme cluster in half
|
||||
for _, test := range []struct {
|
||||
str string
|
||||
limit int
|
||||
expected string
|
||||
}{
|
||||
{"a\u0301b", 1, "a\u0301"},
|
||||
{"a\u0301b", 3, "a\u0301ba\u0301"},
|
||||
{"a\u0301b", 4, "a\u0301ba\u0301b"},
|
||||
{"a\u4e00", 2, "a"},
|
||||
{"a\u4e00", 4, "a\u4e00a"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 1, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 2, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 4, "-\U0001f468\u200d\U0001f469\u200d\U0001f467-"},
|
||||
} {
|
||||
actual := RepeatToFill(test.str, StringWidth(test.str), test.limit)
|
||||
if actual != test.expected {
|
||||
t.Errorf("Expected: %q, actual: %q", test.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringWidth(t *testing.T) {
|
||||
|
||||
@@ -44,19 +44,21 @@ func NewExecutor(withShell string) *Executor {
|
||||
|
||||
shellType := shellTypeUnknown
|
||||
basename := filepath.Base(shell)
|
||||
if len(args) > 0 {
|
||||
args = args[1:]
|
||||
} else if strings.HasPrefix(basename, "cmd") {
|
||||
defaultArgs := []string{"-c"}
|
||||
if strings.HasPrefix(basename, "cmd") {
|
||||
shellType = shellTypeCmd
|
||||
args = []string{"/s/c"}
|
||||
defaultArgs = []string{"/s/c"}
|
||||
} else if strings.HasPrefix(basename, "pwsh") {
|
||||
shellType = shellTypePwsh
|
||||
args = []string{"-NoProfile", "-Command"}
|
||||
defaultArgs = []string{"-NoProfile", "-Command"}
|
||||
} else if strings.HasPrefix(basename, "powershell") {
|
||||
shellType = shellTypePowerShell
|
||||
args = []string{"-NoProfile", "-Command"}
|
||||
defaultArgs = []string{"-NoProfile", "-Command"}
|
||||
}
|
||||
if len(args) > 0 {
|
||||
args = args[1:]
|
||||
} else {
|
||||
args = []string{"-c"}
|
||||
args = defaultArgs
|
||||
}
|
||||
return &Executor{shell: shell, shellType: shellType, args: args}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//go:build windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestNewWindowsExecutor(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
envShell string
|
||||
withShell string
|
||||
shell string
|
||||
shellType shellType
|
||||
args []string
|
||||
}{
|
||||
{"default", "", "", "cmd", shellTypeCmd, []string{"/s/c"}},
|
||||
{"cmd_env", "cmd.exe", "", "cmd.exe", shellTypeCmd, []string{"/s/c"}},
|
||||
{"cmd_path_env", `C:\Windows\System32\cmd.exe`, "", `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/s/c"}},
|
||||
{"powershell_env", "powershell.exe", "", "powershell.exe", shellTypePowerShell, []string{"-NoProfile", "-Command"}},
|
||||
{"pwsh_env", "pwsh.exe", "", "pwsh.exe", shellTypePwsh, []string{"-NoProfile", "-Command"}},
|
||||
{"custom_env", "custom-shell", "", "custom-shell", shellTypeUnknown, []string{"-c"}},
|
||||
{"cmd_explicit", "powershell.exe", "cmd.exe /d /c", "cmd.exe", shellTypeCmd, []string{"/d", "/c"}},
|
||||
{"cmd_path_explicit", "", `C:\Windows\System32\cmd.exe /d /s /c`, `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/d", "/s", "/c"}},
|
||||
{"powershell_explicit", "cmd.exe", "powershell.exe -NoLogo -NonInteractive -Command", "powershell.exe", shellTypePowerShell, []string{"-NoLogo", "-NonInteractive", "-Command"}},
|
||||
{"pwsh_explicit", "cmd.exe", "pwsh.exe -NoLogo -Command", "pwsh.exe", shellTypePwsh, []string{"-NoLogo", "-Command"}},
|
||||
{"custom_explicit", "cmd.exe", "custom-shell --custom -e", "custom-shell", shellTypeUnknown, []string{"--custom", "-e"}},
|
||||
{"cmd_no_flags", "", "cmd.exe", "cmd.exe", shellTypeCmd, []string{}},
|
||||
{"powershell_no_flags", "", "powershell.exe", "powershell.exe", shellTypePowerShell, []string{}},
|
||||
{"pwsh_no_flags", "", "pwsh.exe", "pwsh.exe", shellTypePwsh, []string{}},
|
||||
{"custom_no_flags", "", "custom-shell", "custom-shell", shellTypeUnknown, []string{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("SHELL", test.envShell)
|
||||
x := NewExecutor(test.withShell)
|
||||
if x.shell != test.shell || x.shellType != test.shellType || !reflect.DeepEqual(x.args, test.args) {
|
||||
t.Errorf("got shell=%q type=%v args=%q; want shell=%q type=%v args=%q",
|
||||
x.shell, x.shellType, x.args, test.shell, test.shellType, test.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorQuoteEntry(t *testing.T) {
|
||||
entry := `hello 한글 O'Brien & "quoted"`
|
||||
for _, test := range []struct {
|
||||
withShell string
|
||||
want string
|
||||
}{
|
||||
{"cmd.exe /d /c", `^"hello 한글 O'Brien ^& \^"quoted\^"^"`},
|
||||
{"powershell.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},
|
||||
{"pwsh.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},
|
||||
{"custom-shell -c", `'hello 한글 O'\''Brien & "quoted"'`},
|
||||
} {
|
||||
t.Run(test.withShell, func(t *testing.T) {
|
||||
if got := NewExecutor(test.withShell).QuoteEntry(entry); got != test.want {
|
||||
t.Errorf("got %q; want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorProcessGroup(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
withShell string
|
||||
flags uint32
|
||||
args []string
|
||||
}{
|
||||
{"cmd.exe /d /c", 0, nil},
|
||||
{"powershell.exe -NoLogo -Command", 0, []string{"-NoLogo", "-Command"}},
|
||||
{"pwsh.exe -NoLogo -Command", windows.CREATE_NEW_PROCESS_GROUP, []string{"-NonInteractive", "-NoLogo", "-Command"}},
|
||||
{"custom-shell -e", windows.CREATE_NEW_PROCESS_GROUP, []string{"-e"}},
|
||||
} {
|
||||
t.Run(test.withShell, func(t *testing.T) {
|
||||
x := NewExecutor(test.withShell)
|
||||
originalArgs := append([]string{}, x.args...)
|
||||
for _, setpgid := range []bool{false, true} {
|
||||
cmd := x.ExecCommand("echo hello", setpgid)
|
||||
var wantFlags uint32
|
||||
if setpgid {
|
||||
wantFlags = test.flags
|
||||
}
|
||||
if cmd.SysProcAttr.CreationFlags != wantFlags {
|
||||
t.Errorf("setpgid=%v: got flags=%v; want %v", setpgid, cmd.SysProcAttr.CreationFlags, wantFlags)
|
||||
}
|
||||
if strings.HasPrefix(test.withShell, "cmd") {
|
||||
if cmd.SysProcAttr.CmdLine != `/d /c "echo hello"` {
|
||||
t.Errorf("got CMD command line %q", cmd.SysProcAttr.CmdLine)
|
||||
}
|
||||
} else {
|
||||
wantArgs := test.args
|
||||
if !setpgid && strings.HasPrefix(test.withShell, "pwsh") {
|
||||
wantArgs = wantArgs[1:]
|
||||
}
|
||||
want := append(append([]string{cmd.Args[0]}, wantArgs...), "echo hello")
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Errorf("setpgid=%v: got args=%q; want %q", setpgid, cmd.Args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(x.args, originalArgs) {
|
||||
t.Errorf("ExecCommand changed executor arguments: %q", x.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorQuotedCommand(t *testing.T) {
|
||||
// Run the test binary as a helper so this regression needs no external tools.
|
||||
// Both its path and its argument exercise Windows shell quoting.
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(executable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "quoted path 한글 & O'Brien")
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
helper := filepath.Join(dir, "shell-probe.exe")
|
||||
if err := os.WriteFile(helper, data, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
shell string
|
||||
withShell string
|
||||
prefix string
|
||||
}{
|
||||
{"cmd.exe", "", ""},
|
||||
{"cmd.exe", "cmd.exe /d /c", ""},
|
||||
{"powershell.exe", "", "& "},
|
||||
{"powershell.exe", "powershell.exe -NoProfile -NonInteractive -Command", "& "},
|
||||
{"pwsh.exe", "", "& "},
|
||||
{"pwsh.exe", "pwsh.exe -NoProfile -Command", "& "},
|
||||
} {
|
||||
name := "default"
|
||||
if test.withShell != "" {
|
||||
name = "explicit"
|
||||
}
|
||||
t.Run(test.shell+"/"+name, func(t *testing.T) {
|
||||
if _, err := exec.LookPath(test.shell); err != nil {
|
||||
t.Skipf("%s is unavailable: %v", test.shell, err)
|
||||
}
|
||||
t.Setenv("SHELL", test.shell)
|
||||
x := NewExecutor(test.withShell)
|
||||
entry := `hello 한글 O'Brien &|<>()^%!`
|
||||
if test.prefix == "" {
|
||||
entry += ` "quoted" C:\trailing\`
|
||||
}
|
||||
command := test.prefix + `"` + helper + `" ` + x.QuoteEntry("-test.run=TestWindowsExecutorHelper") + " -- " + x.QuoteEntry(entry)
|
||||
cmd := x.ExecCommand(command, true)
|
||||
cmd.Env = append(os.Environ(), "FZF_WINDOWS_EXECUTOR_HELPER=1")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timer := time.AfterFunc(10*time.Second, func() { _ = cmd.Process.Kill() })
|
||||
defer timer.Stop()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("command failed: %v\n%s", err, output.String())
|
||||
}
|
||||
var got []string
|
||||
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid helper output: %v\n%s", err, output.String())
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{entry}) {
|
||||
t.Errorf("got arguments %q; want %q", got, []string{entry})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorHelper(t *testing.T) {
|
||||
if os.Getenv("FZF_WINDOWS_EXECUTOR_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
for index, arg := range os.Args {
|
||||
if arg == "--" {
|
||||
if err := json.NewEncoder(os.Stdout).Encode(os.Args[index+1:]); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
+189
-1
@@ -479,6 +479,19 @@ 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] }
|
||||
@@ -1519,6 +1532,61 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| refute_includes(lines[-1], '[[1]]') }
|
||||
end
|
||||
|
||||
def assert_focus_log(expected)
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
assert_equal expected, File.readlines(tempname, chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload
|
||||
# Without --id-nth, the item at the same index after reload is a new item
|
||||
tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> a' }
|
||||
assert_focus_log(%w[a])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> c' }
|
||||
assert_focus_log(%w[a c])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_same_key
|
||||
# Same --id-nth key after reload: no focus event
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 1 aa' }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_key_not_found
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 3 c' }
|
||||
assert_focus_log(['1 a', '3 c'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_async_reload_id_nth
|
||||
# No focus event for the items shown before the tracked item arrives.
|
||||
# 'result' binding makes fzf redraw the list while blocked.
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, '> 1 aa'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_result_event
|
||||
tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter
|
||||
tmux.until { |lines| assert_equal 11, lines.match_count }
|
||||
@@ -2274,6 +2342,94 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_next_value
|
||||
tmux.send_keys %(echo 'a b c d' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|3|4),x:change-nth(3),result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[2]>') }
|
||||
# Changed by another binding. The next value is the one after '3'.
|
||||
tmux.send_keys :x
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[4]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_all_fields_is_current
|
||||
# '--nth ..' is stored as an empty list, which must match '..'
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth .. --bind 'space:change-nth(..|1|2)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_repeated_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|1|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
%w[2 1 3 1 2].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_skips_invalid_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 2 --bind 'space:change-nth(1|x|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[2]>') }
|
||||
%w[1 3 1].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_only_invalid_values_left
|
||||
# 'x' is invalid and '3' is current, so nothing changes
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(x|3)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('S[3]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_invalid_value_ignored
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(bad)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'b:transform-nth(echo bad)+transform-prompt:echo "T[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('S[3]>') }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert lines.any_include?('T[3]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_from_transform
|
||||
# A new action is created on each run, so the next value is found from the current state
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:transform:echo "change-nth(1|2|3)"' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
%w[2 3 1].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_skips_default_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(|2)'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
# Empty value is the default (field 1), which is current
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> b') }
|
||||
end
|
||||
|
||||
def test_change_with_nth_skips_invalid_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(1|foo|2)'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> b') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
end
|
||||
|
||||
def test_transform_with_nth_search
|
||||
input = [
|
||||
'alpha bravo charlie',
|
||||
@@ -2306,6 +2462,25 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
|
||||
end
|
||||
|
||||
def test_transform_nth_output_does_not_replace_command
|
||||
# '|' in the output must not be treated as a list to cycle through
|
||||
File.write(tempname, '')
|
||||
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind "space:transform-nth(echo >> #{tempname}; echo '1|2')+transform-with-nth(echo >> #{tempname}; echo '1|2')"), :Enter
|
||||
tmux.until { |lines| assert_equal 1, lines.item_count }
|
||||
3.times do |i|
|
||||
tmux.send_keys :Space
|
||||
wait { assert_equal 2 * (i + 1), File.readlines(tempname).length }
|
||||
end
|
||||
end
|
||||
|
||||
def test_transform_with_nth_output_with_pipe
|
||||
File.write(tempname, '{2}|{1}')
|
||||
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind 'space:transform-with-nth(cat #{tempname})'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('a b') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('b|a') }
|
||||
end
|
||||
|
||||
# Regression: actions emitted by bg-transform must affect the iteration that
|
||||
# processes the async result, not the (no-longer-active) iteration that
|
||||
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
|
||||
@@ -2858,7 +3033,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_change_nth_unset_default
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:), :Enter
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:..), :Enter
|
||||
tmux.until do
|
||||
assert_equal 1, it.item_count
|
||||
assert_equal 0, it.match_count
|
||||
@@ -2872,6 +3047,19 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_empty_restores_default
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind 'a:change-nth(..),space:change-nth(),b:transform-nth(true)'), :Enter
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
end
|
||||
|
||||
def test_change_header_lines
|
||||
tmux.send_keys %(seq 10 | #{FZF} --header-lines 3 --bind 'space:change-header-lines(5),enter:transform-header-lines(echo 1)'), :Enter
|
||||
tmux.until do |lines|
|
||||
|
||||
@@ -25,6 +25,15 @@ class TestFilter < TestBase
|
||||
assert_equal 1, `echo Foo bar | #{FZF} -x -f "foo Fbar" | wc -l`.to_i
|
||||
end
|
||||
|
||||
def test_filter_no_sort
|
||||
input = "printf 'abz\\nab\\nzab\\n'"
|
||||
['', '--sync'].each do |opts|
|
||||
assert_equal %w[ab abz zab], `#{input} | #{FZF} -f ab #{opts}`.lines(chomp: true)
|
||||
assert_equal %w[abz ab zab], `#{input} | #{FZF} -f ab --no-sort #{opts}`.lines(chomp: true)
|
||||
assert_equal %w[zab ab abz], `#{input} | #{FZF} -f ab --no-sort --tac #{opts}`.lines(chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_exitstatus
|
||||
# filter / streaming filter
|
||||
['', '--no-sort'].each do |opts|
|
||||
|
||||
@@ -882,6 +882,28 @@ class TestLayout < TestInteractive
|
||||
tmux.until { assert_block(block, it) }
|
||||
end
|
||||
|
||||
def test_adaptive_height_with_inline_sections
|
||||
tmux.send_keys %(seq 10 | #{FZF} --height=~100% --list-border --header-lines=1 --header-lines-border=inline), :Enter
|
||||
block = <<~BLOCK
|
||||
╭──────
|
||||
│ 10
|
||||
│ 9
|
||||
│ 8
|
||||
│ 7
|
||||
│ 6
|
||||
│ 5
|
||||
│ 4
|
||||
│ 3
|
||||
│ > 2
|
||||
├──────
|
||||
│ 1
|
||||
╰──────
|
||||
9/9
|
||||
>
|
||||
BLOCK
|
||||
tmux.until { assert_block(block, it) }
|
||||
end
|
||||
|
||||
def test_style_full_adaptive_height
|
||||
tmux.send_keys %(seq 1| #{FZF} --style=full:rounded --height=~100% --header-lines=1 --info=default), :Enter
|
||||
block = <<~BLOCK
|
||||
|
||||
@@ -55,6 +55,29 @@ class TestServer < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_listen_progress
|
||||
tmux.send_keys "seq 10 | #{FZF} --listen 6266", :Enter
|
||||
tmux.until { |lines| assert_equal 10, lines.match_count }
|
||||
uri = URI('http://localhost:6266')
|
||||
state = -> { JSON.parse(Net::HTTP.get(uri), symbolize_names: true) }
|
||||
|
||||
# Idle: the last search is complete
|
||||
assert_equal 100, state.call[:progress]
|
||||
|
||||
# While a search is running, progress is not left at 100 from the
|
||||
# previous one, so it can tell a settled snapshot from a stale one
|
||||
Net::HTTP.post(uri, 'reload(sleep 1; seq 100)')
|
||||
tmux.until { assert_equal 0, state.call[:progress] }
|
||||
|
||||
# Settled: matches belong to the snapshot that reports 100
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
tmux.until do
|
||||
st = state.call
|
||||
assert_equal 100, st[:progress]
|
||||
assert_equal 100, st[:matchCount]
|
||||
end
|
||||
end
|
||||
|
||||
def test_listen_with_api_key
|
||||
uri = URI('http://localhost:6266')
|
||||
tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1273,6 +1273,43 @@ class TestNushell < TestBase
|
||||
FileUtils.rm_rf('/tmp/fzf-test')
|
||||
end
|
||||
|
||||
# Override: paths are inserted as Nushell string literals, so the
|
||||
# selections appear quoted on the command line.
|
||||
def test_ctrl_t
|
||||
set_var('FZF_CTRL_T_COMMAND', 'seq 100')
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
tmux.send_keys :Tab, :Tab, :Tab
|
||||
tmux.until { |lines| assert lines.any_include?(' (3)') }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('"1" "2" "3"') }
|
||||
tmux.send_keys 'C-c'
|
||||
end
|
||||
|
||||
# A path is inserted as a Nushell string literal, so that syntax in a file
|
||||
# name is not evaluated and each path stays a single argument.
|
||||
def test_ctrl_t_quoting
|
||||
marker = "#{tempname}-marker"
|
||||
FileUtils.rm_f(marker)
|
||||
writelines(["fzf-inject$(touch #{marker}).txt", 'fzf-inject space.txt'])
|
||||
set_var('FZF_CTRL_T_COMMAND', "cat #{tempname}")
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys '^printf "%s\n" ', 'C-t'
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
tmux.send_keys :Tab, :Tab
|
||||
tmux.until { |lines| assert_equal 2, lines.select_count }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert_includes lines[-1].to_s, '"fzf-inject$(touch' }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal ["fzf-inject$(touch #{marker}).txt", 'fzf-inject space.txt'], lines[-2..]
|
||||
end
|
||||
refute_path_exists marker
|
||||
end
|
||||
|
||||
# Nushell does not support multiline command recall the same way
|
||||
# as bash/zsh/fish, so test_ctrl_r_multiline is omitted.
|
||||
|
||||
|
||||
+92
-12
@@ -4,15 +4,44 @@ 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 = fzf#run({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir })
|
||||
let result = g:FzfRun({ '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(fzf#run({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir }))
|
||||
let result = sort(g:FzfRun({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir }))
|
||||
AssertEqual ['fzf.vader'], result
|
||||
AssertEqual 1, haslocaldir()
|
||||
AssertEqual getcwd(), cwd
|
||||
@@ -22,32 +51,32 @@ Execute (fzf#run with Funcref command):
|
||||
function! g:FzfTest(e)
|
||||
call add(g:ret, a:e)
|
||||
endfunction
|
||||
let result = sort(fzf#run({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir }))
|
||||
let result = sort(g:FzfRun({ '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(fzf#run({ 'source': 'echo hi', 'options': '-f i' }))
|
||||
let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' }))
|
||||
AssertEqual ['hi'], result
|
||||
|
||||
Execute (fzf#run with list source):
|
||||
let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f e' }))
|
||||
let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f e' }))
|
||||
AssertEqual ['hello'], result
|
||||
let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f o' }))
|
||||
let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f o' }))
|
||||
AssertEqual ['hello', 'world'], result
|
||||
|
||||
Execute (fzf#run with string source):
|
||||
let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' }))
|
||||
let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' }))
|
||||
AssertEqual ['hi'], result
|
||||
|
||||
Execute (fzf#run with dir option and noautochdir):
|
||||
set noacd
|
||||
let cwd = getcwd()
|
||||
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
|
||||
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
|
||||
" No change in working directory
|
||||
AssertEqual cwd, getcwd()
|
||||
|
||||
call fzf#run({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'})
|
||||
call g:FzfRun({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'})
|
||||
AssertEqual cwd, getcwd()
|
||||
tabclose
|
||||
AssertEqual cwd, getcwd()
|
||||
@@ -55,13 +84,13 @@ Execute (fzf#run with dir option and noautochdir):
|
||||
Execute (Incomplete fzf#run with dir option and autochdir):
|
||||
set acd
|
||||
let cwd = getcwd()
|
||||
call fzf#run({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'})
|
||||
call g:FzfRun({'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 fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
|
||||
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
|
||||
" Working directory changed due to &acd
|
||||
AssertEqual '/foobar', expand('%')
|
||||
AssertEqual '/', getcwd()
|
||||
@@ -69,13 +98,40 @@ 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 fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'})
|
||||
call g:FzfRun({'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
|
||||
@@ -150,6 +206,30 @@ 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')
|
||||
|
||||
Reference in new issue
Block a user