mirror of
https://github.com/junegunn/fzf.git
synced 2026-08-18 05:38:05 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f64c492a | ||
|
|
bd4efa277b | ||
|
|
63f6cfec5b | ||
|
|
d4b6ba781c | ||
|
|
85a2a33612 | ||
|
|
ca4c1b80e3 | ||
|
|
dab626bd9e | ||
|
|
2885df8395 | ||
|
|
715d26fa39 | ||
|
|
ab1ee05e51 | ||
|
|
7b16e44f53 | ||
|
|
9dfdba41f5 | ||
|
|
465837f3ad | ||
|
|
a650900eda | ||
|
|
793e58b558 | ||
|
|
759b7c3283 | ||
|
|
0579bb0e0d |
@@ -1,6 +1,19 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
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
|
||||
|
||||
@@ -102,7 +102,7 @@ itest:
|
||||
# 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
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ triggered by a tag push.
|
||||
2. Verify file consistency, sign the tag, and push the tag.
|
||||
|
||||
```sh
|
||||
make tag VERSION=0.74.2
|
||||
make tag VERSION=0.74.3
|
||||
```
|
||||
|
||||
`make tag` runs `prerelease` first (checks that the version
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.74.2
|
||||
version=0.74.3
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.74.2"
|
||||
$version="0.74.3"
|
||||
|
||||
$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 "Aug 2026" "fzf 0.74.3" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+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 1 "Aug 2026" "fzf 0.74.2" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "Aug 2026" "fzf 0.74.3" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-2
@@ -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
|
||||
@@ -2276,6 +2280,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 +4861,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 {
|
||||
@@ -6421,7 +6465,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, ""})
|
||||
@@ -7369,7 +7417,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:
|
||||
|
||||
+78
-8
@@ -223,7 +223,7 @@ func TestReplacePlaceholder(t *testing.T) {
|
||||
// while the double q is invalid, it is useful here for testing purposes
|
||||
templateToOutput[`{q}`] = "{{.O}}" + query + "{{.O}}"
|
||||
templateToOutput[`{fzf:query}`] = "{{.O}}" + query + "{{.O}}"
|
||||
templateToOutput[`{fzf:action} {fzf:prompt}`] = "backward-delete-char-eof 'prompt'"
|
||||
templateToOutput[`{fzf:action} {fzf:prompt}`] = `backward-delete-char-eof {{.O}}prompt{{.O}}`
|
||||
|
||||
// IV. escaping placeholder
|
||||
templateToOutput[`\{}`] = `{}`
|
||||
@@ -251,9 +251,9 @@ 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
|
||||
exec := util.NewExecutor("")
|
||||
|
||||
@@ -280,13 +280,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 +440,7 @@ func TestPowershellCommands(t *testing.T) {
|
||||
// to explorer, which will prompt user to pick editing program for the fzf-preview file
|
||||
// the temp file contains: `cat "C:\test.txt"`
|
||||
// TODO this should actually work
|
||||
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-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 +851,73 @@ 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-5
@@ -24,15 +24,44 @@ const (
|
||||
defaultEscDelay = 100
|
||||
escPollInterval = 5
|
||||
offsetPollTries = 10
|
||||
queryTimeout = 500 * time.Millisecond
|
||||
maxInputBuffer = 1024 * 1024
|
||||
maxSelectTries = 100
|
||||
)
|
||||
|
||||
const DefaultTtyDevice string = "/dev/tty"
|
||||
|
||||
var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
|
||||
var offsetRegexp = regexp.MustCompile("\x00?\x1b\\[([0-9]+);([0-9]+)R")
|
||||
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
|
||||
|
||||
// DECRPM reply to the DECRQM query for bracketed paste mode. Ps is 1 or 3 when
|
||||
// the mode was already set, 2 or 4 when reset, 0 when the terminal does not
|
||||
// recognize the mode.
|
||||
var pasteModeRegexp = regexp.MustCompile("\x00?\x1b\\[\\?2004;([0-4])\\$y")
|
||||
var pasteModeRegexpBegin = regexp.MustCompile("^\x1b\\[\\?2004;[0-4]\\$y")
|
||||
|
||||
// A report to ask the terminal for, and the reply to recognize it by.
|
||||
type termQuery struct {
|
||||
seq string
|
||||
reply *regexp.Regexp
|
||||
}
|
||||
|
||||
var offsetQuery = termQuery{"6n", offsetRegexp}
|
||||
var pasteModeQuery = termQuery{"?2004$p", pasteModeRegexp}
|
||||
|
||||
// What we ask the terminal at startup, in the order the queries go out.
|
||||
// A terminal answers them in that order, so the cursor position query is last
|
||||
// and also ends the wait: every terminal fzf supports answers it, so once its
|
||||
// reply arrives, a query still unanswered is one the terminal does not know
|
||||
// rather than one we stopped waiting for too early.
|
||||
//
|
||||
// Terminals that don't support the paste mode query (DECRQM) might leave
|
||||
// 'p' on the screen. To handle such cases, we query the position before and
|
||||
// after it, compare them, and clean the artifact if they don't match.
|
||||
//
|
||||
// Reference: https://ansicode.eversources.app/en/sequence/decrqm
|
||||
var startupQueries = []termQuery{offsetQuery, pasteModeQuery, offsetQuery}
|
||||
|
||||
func (r *LightRenderer) Bell() {
|
||||
r.flushRaw("\a")
|
||||
}
|
||||
@@ -158,6 +187,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 +263,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
|
||||
@@ -318,7 +356,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 {
|
||||
@@ -446,6 +488,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}
|
||||
@@ -1019,7 +1067,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) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build !windows
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drives queryStartup against a terminal simulated by pipes, with the replies
|
||||
// already queued so the exchange is deterministic.
|
||||
func replyingTerminal(t *testing.T, replies string) (*LightRenderer, func() string) {
|
||||
t.Helper()
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outR, outW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inW.WriteString(replies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close(); inW.Close(); outR.Close() })
|
||||
|
||||
r := &LightRenderer{ttyin: inR, ttyout: outW, escDelay: defaultEscDelay}
|
||||
written := func() string {
|
||||
outW.Close()
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := outR.Read(buf)
|
||||
sb.Write(buf[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
return r, written
|
||||
}
|
||||
|
||||
const eraseEcho = "\b \b"
|
||||
|
||||
func TestQueryStartup(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
replies string
|
||||
row, col int
|
||||
paste string // "", "true" or "false"
|
||||
erase int // columns the echo took, and so erasures expected
|
||||
}{
|
||||
{
|
||||
name: "mode already set",
|
||||
replies: "\x1b[5;10R\x1b[?2004;1$y\x1b[5;10R",
|
||||
row: 4, col: 9, paste: "true",
|
||||
},
|
||||
{
|
||||
name: "mode reset",
|
||||
replies: "\x1b[5;10R\x1b[?2004;2$y\x1b[5;10R",
|
||||
row: 4, col: 9, paste: "false",
|
||||
},
|
||||
{
|
||||
name: "mode not recognized",
|
||||
replies: "\x1b[5;10R\x1b[?2004;0$y\x1b[5;10R",
|
||||
row: 4, col: 9,
|
||||
},
|
||||
{
|
||||
// Answers the position queries but ignores DECRQM without printing
|
||||
name: "query ignored",
|
||||
replies: "\x1b[5;10R\x1b[5;10R",
|
||||
row: 4, col: 9,
|
||||
},
|
||||
{
|
||||
// macOS Terminal.app: ends the sequence at '$' and prints the 'p'
|
||||
name: "query echoed",
|
||||
replies: "\x1b[5;10R\x1b[5;11R",
|
||||
row: 4, col: 9, erase: 1,
|
||||
},
|
||||
{
|
||||
// A terminal that prints more of what it could not parse
|
||||
name: "longer echo",
|
||||
replies: "\x1b[5;10R\x1b[5;13R",
|
||||
row: 4, col: 9, erase: 3,
|
||||
},
|
||||
{
|
||||
// The echo wrapped to the next line, where erasing would guess
|
||||
name: "echo wrapped",
|
||||
replies: "\x1b[5;80R\x1b[6;1R",
|
||||
row: 4, col: 79,
|
||||
},
|
||||
{
|
||||
name: "no reply at all",
|
||||
replies: "",
|
||||
row: -1, col: -1,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, written := replyingTerminal(t, tc.replies)
|
||||
row, col, pasteWasSet := r.queryStartup()
|
||||
|
||||
if row != tc.row || col != tc.col {
|
||||
t.Errorf("offset (%d,%d), want (%d,%d)", row, col, tc.row, tc.col)
|
||||
}
|
||||
paste := ""
|
||||
if pasteWasSet != nil {
|
||||
paste = "false"
|
||||
if *pasteWasSet {
|
||||
paste = "true"
|
||||
}
|
||||
}
|
||||
if paste != tc.paste {
|
||||
t.Errorf("pasteWasSet %q, want %q", paste, tc.paste)
|
||||
}
|
||||
|
||||
out := written()
|
||||
if got := strings.Count(out, eraseEcho); got != tc.erase {
|
||||
t.Errorf("erased %d columns, want %d (wrote %q)", got, tc.erase, out)
|
||||
}
|
||||
// The paste mode query has to sit between two position queries
|
||||
if want := "\x1b[6n\x1b[?2004$p\x1b[6n"; !strings.Contains(out, want) {
|
||||
t.Errorf("queries %q, want %q in order", out, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+110
-14
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user