Compare commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 13:43:49 +00:00
25 changed files with 78 additions and 1556 deletions
+3 -3
View File
@@ -33,12 +33,12 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v4.37.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v4 uses: github/codeql-action/autobuild@v4.37.4
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@v4.37.4
-13
View File
@@ -1,19 +1,6 @@
CHANGELOG 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 0.74.2
------ ------
- Performance optimizations for short queries - Performance optimizations for short queries
+1 -1
View File
@@ -102,7 +102,7 @@ itest:
# FUZZTIME (e.g. make fuzz FUZZTIME=5m). # FUZZTIME (e.g. make fuzz FUZZTIME=5m).
FUZZTIME ?= 30s FUZZTIME ?= 30s
fuzz: fuzz:
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \ @for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two; do \
echo "== $$t =="; \ echo "== $$t =="; \
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \ $(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
done done
+1 -1
View File
@@ -18,7 +18,7 @@ triggered by a tag push.
2. Verify file consistency, sign the tag, and push the tag. 2. Verify file consistency, sign the tag, and push the tag.
```sh ```sh
make tag VERSION=0.74.3 make tag VERSION=0.74.2
``` ```
`make tag` runs `prerelease` first (checks that the version `make tag` runs `prerelease` first (checks that the version
+1 -1
View File
@@ -2,7 +2,7 @@
set -u set -u
version=0.74.3 version=0.74.2
auto_completion= auto_completion=
key_bindings= key_bindings=
update_config=2 update_config=2
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.3" $version="0.74.2"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+1 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.3" "fzf\-tmux - open fzf in tmux split pane" .TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.2" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME .SH NAME
fzf\-tmux - open fzf in tmux split pane fzf\-tmux - open fzf in tmux split pane
+1 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf 1 "Aug 2026" "fzf 0.74.3" "fzf - a command-line fuzzy finder" .TH fzf 1 "Aug 2026" "fzf 0.74.2" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
+5 -88
View File
@@ -303,9 +303,7 @@ func bonusAt(input *util.Chars, idx int) int16 {
} }
func normalizeRune(r rune) rune { func normalizeRune(r rune) rune {
// Every key of the map folds to ASCII, so a rune the bitmap rejects cannot if r < 0x00C0 || r > 0xFF61 {
// be in it. TestNormalizedKeysAreFlagged verifies that.
if !util.MayFoldToAscii(r) {
return r return r
} }
@@ -347,90 +345,10 @@ func isAscii(runes []rune) bool {
return true return true
} }
// runePrefilterable reports whether scanning the rune array can decide this
// pattern against this item without missing a match. Phase 2 lowercases an
// uppercase text rune and then normalizes it, and the scan sees neither
// transform, so every pattern rune must be unreachable by them.
func runePrefilterable(input *util.Chars, pattern []rune, caseSensitive bool) bool {
if input.MayFoldToAscii() {
// A non-ASCII rune of this item could fold onto an ASCII pattern char
for _, r := range pattern {
if r < utf8.RuneSelf {
return false
}
}
}
if caseSensitive {
// No case transform is applied, and normalization only ever produces
// ASCII, so nothing can reach a non-ASCII pattern rune
return true
}
for _, r := range pattern {
// Another rune must not lowercase onto this one. Being uncased is not
// enough by itself: U+00DF has no simple uppercase yet U+1E9E
// lowercases to it. Excluding the foldable set covers that.
if r >= utf8.RuneSelf &&
(unicode.ToUpper(r) != r || unicode.ToLower(r) != r || util.MayFoldToAscii(r)) {
return false
}
}
return true
}
// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when
// runePrefilterable says so.
func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
runes := input.Runes()
firstIdx, idx, lastIdx := 0, 0, 0
var last rune
for pidx := range pattern {
last = pattern[pidx]
if last < utf8.RuneSelf {
idx = indexAsciiRune(runes, caseSensitive, byte(last), idx)
} else {
idx = indexRune(runes, last, idx)
}
if idx < 0 {
return -1, -1
}
if pidx == 0 && idx > 0 {
// Step back to find the right bonus point
firstIdx = idx - 1
}
lastIdx = idx
idx++
}
// Find the last appearance of the last character of the pattern to limit
// the search scope
if lastIdx+1 < len(runes) {
var end int
if last < utf8.RuneSelf {
end = lastIndexAsciiRune(runes, caseSensitive, byte(last), lastIdx+1)
} else {
end = lastIndexRune(runes, last, lastIdx+1)
}
if end >= 0 {
return firstIdx, end + 1
}
}
return firstIdx, lastIdx + 1
}
func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) { func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
// Can't determine
if !input.IsBytes() { if !input.IsBytes() {
if disableRunePrefilter { return 0, input.Length()
return 0, input.Length()
}
// runePrefilterable does not inline, so keep the common case out of
// it: an ASCII pattern against an item that cannot fold to ASCII is
// always scannable, and both of these checks do inline.
if input.MayFoldToAscii() || !isAscii(pattern) {
if !runePrefilterable(input, pattern, caseSensitive) {
return 0, input.Length()
}
}
return runeFuzzyIndex(input, pattern, caseSensitive)
} }
// Not possible // Not possible
@@ -548,9 +466,8 @@ func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b b
// Test hooks: force the general path instead of a fast path, so the two can // Test hooks: force the general path instead of a fast path, so the two can
// be compared for equivalence. // be compared for equivalence.
var ( var (
disableSingle bool disableSingle bool
disableTwo bool disableTwo bool
disableRunePrefilter bool
) )
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on // fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
+1 -1
View File
@@ -1,7 +1,7 @@
package algo package algo
// Equivalence tests for the single- and two-character fast paths against the // Equivalence tests for the single- and two-character fast paths against the
// general FuzzyMatchV2 algorithm, which serves as the reference. // general FuzzyMatchV2 algorithm, which serves as the oracle.
// //
// Two complementary strategies: // Two complementary strategies:
// - Exhaustive: every string up to a fixed length over an alphabet that // - Exhaustive: every string up to a fixed length over an alphabet that
-23
View File
@@ -1,23 +0,0 @@
//go:build !386 && !amd64 && !arm64
package algo
// The byte-view scanners in runeindex_x86.go reinterpret a []rune as
// little-endian 4-byte lanes, which is not valid everywhere. Elsewhere the
// reference scanners are the implementation.
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
return indexAsciiRuneRef(runes, caseSensitive, b, from)
}
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
return lastIndexAsciiRuneRef(runes, caseSensitive, b, from)
}
func indexRune(runes []rune, r rune, from int) int {
return indexRuneRef(runes, r, from)
}
func lastIndexRune(runes []rune, r rune, from int) int {
return lastIndexRuneRef(runes, r, from)
}
-55
View File
@@ -1,55 +0,0 @@
package algo
// Reference scanners over a []rune, with no representation tricks.
//
// They have two roles. Where reinterpreting a []rune as little-endian bytes is
// not valid, they are the shipped implementation, via runeindex_others.go.
// Everywhere else, the tests feed the same inputs to these and to the byte-view
// scanners in runeindex_x86.go and require identical answers.
//
// They carry no build tag so that both roles hold on every platform. Otherwise
// the portable build would be code that nothing here ever runs.
func indexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
lower, upper := rune(b), rune(-1)
if !caseSensitive && b >= 'a' && b <= 'z' {
upper = rune(b - 32)
}
for i := from; i < len(runes); i++ {
if runes[i] == lower || runes[i] == upper {
return i
}
}
return -1
}
func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
lower, upper := rune(b), rune(-1)
if !caseSensitive && b >= 'a' && b <= 'z' {
upper = rune(b - 32)
}
for i := len(runes) - 1; i >= from; i-- {
if runes[i] == lower || runes[i] == upper {
return i
}
}
return -1
}
func indexRuneRef(runes []rune, r rune, from int) int {
for i := from; i < len(runes); i++ {
if runes[i] == r {
return i
}
}
return -1
}
func lastIndexRuneRef(runes []rune, r rune, from int) int {
for i := len(runes) - 1; i >= from; i-- {
if runes[i] == r {
return i
}
}
return -1
}
-128
View File
@@ -1,128 +0,0 @@
//go:build 386 || amd64 || arm64
package algo
import (
"bytes"
"unsafe"
)
// On these architectures a []rune is a little-endian array of 4-byte lanes, so
// an ASCII rune is the byte itself followed by three zero bytes at a 4-byte
// aligned offset. That lets the SIMD byte scanners run over the rune array
// directly: find the low byte, then confirm alignment and the three zeroes.
// A byte equal to the needle can also appear as the low byte of a multi-byte
// rune (0x0165 has low byte 'e'), which those two checks reject.
func runeBytes(runes []rune) []byte {
return unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(runes))), len(runes)*4)
}
// indexAsciiRune returns the index of the first rune equal to b, or to its
// uppercase form when ignoring case, at or after rune index from.
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
view := runeBytes(runes)
both := !caseSensitive && b >= 'a' && b <= 'z'
for off := from * 4; off < len(view); {
var idx int
if both {
idx = IndexByteTwo(view[off:], b, b-32)
} else {
idx = bytes.IndexByte(view[off:], b)
}
if idx < 0 {
return -1
}
pos := off + idx
if pos&3 == 0 && view[pos+1]|view[pos+2]|view[pos+3] == 0 {
return pos >> 2
}
off = pos + 1
}
return -1
}
// runeNeedle picks which of the rune's four bytes to scan for, and returns its
// lane index and value. A zero byte is a useless needle because every ASCII
// rune contributes three of them, so U+AE00 scanned by its low byte would hit
// on almost every character of an ASCII-heavy line. Prefer a byte that cannot
// occur in an ASCII rune at all, then any non-zero byte.
func runeNeedle(r rune) (int, byte) {
var b [4]byte
b[0], b[1], b[2], b[3] = byte(r), byte(r>>8), byte(r>>16), byte(r>>24)
for i, v := range b {
if v >= 0x80 {
return i, v
}
}
for i, v := range b {
if v != 0 {
return i, v
}
}
return 0, 0
}
func runeAt(view []byte, start int) rune {
return rune(view[start]) | rune(view[start+1])<<8 | rune(view[start+2])<<16 | rune(view[start+3])<<24
}
// indexRune returns the index of the first rune equal to r at or after rune
// index from. Case is not folded, so the caller must have established that no
// other rune can transform into r.
func indexRune(runes []rune, r rune, from int) int {
view := runeBytes(runes)
lane, needle := runeNeedle(r)
for off := from*4 + lane; off < len(view); {
idx := bytes.IndexByte(view[off:], needle)
if idx < 0 {
return -1
}
pos := off + idx
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
return start >> 2
}
off = pos + 1
}
return -1
}
// lastIndexRune is indexRune scanning backwards from the end.
func lastIndexRune(runes []rune, r rune, from int) int {
view := runeBytes(runes)
lane, needle := runeNeedle(r)
for end := len(view); end > from*4+lane; {
idx := bytes.LastIndexByte(view[from*4+lane:end], needle)
if idx < 0 {
return -1
}
pos := from*4 + lane + idx
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
return start >> 2
}
end = pos
}
return -1
}
// lastIndexAsciiRune is indexAsciiRune scanning backwards from the end.
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
view := runeBytes(runes)[from*4:]
both := !caseSensitive && b >= 'a' && b <= 'z'
for end := len(view); end > 0; {
var idx int
if both {
idx = lastIndexByteTwo(view[:end], b, b-32)
} else {
idx = bytes.LastIndexByte(view[:end], b)
}
if idx < 0 {
return -1
}
if idx&3 == 0 && view[idx+1]|view[idx+2]|view[idx+3] == 0 {
return from + idx>>2
}
end = idx
}
return -1
}
-479
View File
@@ -1,479 +0,0 @@
package algo
// Correctness tests for the rune-array prefilter (Step C).
//
// The prefilter may narrow the search scope but must never change a Result or
// its positions, and must never reject an item the general path would match.
// Each result is compared against the same code with the prefilter disabled.
import (
"math/rand"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/junegunn/fzf/src/util"
)
// foldForTest mirrors what Phase 2 does to a non-ASCII text rune: lowercase if
// uppercase, then normalize.
func foldForTest(r rune, normalize bool) rune {
if charClassOfNonAscii(r) == charUpper {
r = unicode.To(unicode.LowerCase, r)
}
if normalize {
r = normalizeRune(r)
}
return r
}
// The prefilter is only safe on items whose runes cannot become ASCII. This
// verifies util.MayFoldToAscii as a superset of the runes that actually can,
// over the whole Unicode range and both normalization modes. If normalize.go
// or the Go unicode tables change, this fails.
func TestMayFoldToAsciiIsSuperset(t *testing.T) {
missed := 0
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
for _, normalize := range []bool{true, false} {
if foldForTest(r, normalize) < utf8.RuneSelf && !util.MayFoldToAscii(r) {
if missed++; missed < 10 {
t.Errorf("U+%04X folds to ASCII (normalize=%v) but MayFoldToAscii is false", r, normalize)
}
}
}
}
if missed > 0 {
t.Fatalf("%d runes fold to ASCII without being flagged", missed)
}
}
// Scripts that must stay unflagged, otherwise the prefilter never runs for
// them and Step C has no effect.
func TestMayFoldToAsciiExcludesMajorScripts(t *testing.T) {
for _, s := range []struct {
name string
lo, hi rune
}{
{"Cyrillic", 0x0400, 0x04FF}, {"Greek", 0x0370, 0x03FF}, {"Hebrew", 0x0590, 0x05FF},
{"Arabic", 0x0600, 0x06FF}, {"Thai", 0x0E00, 0x0E7F}, {"Devanagari", 0x0900, 0x097F},
{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
{"box drawing", 0x2500, 0x257F}, {"emoji", 0x1F300, 0x1FAFF},
// These sit between the Latin blocks and were included in an earlier,
// wider grouping of foldableRanges. General Punctuation matters most:
// curly quotes, en and em dashes and the ellipsis are in it.
{"Greek Extended", 0x1F00, 0x1FFF}, {"General Punctuation", 0x2000, 0x206F},
{"Currency Symbols", 0x20A0, 0x20CF}, {"CJK Symbols", 0x3000, 0x303F},
} {
for r := s.lo; r <= s.hi; r++ {
if util.MayFoldToAscii(r) {
t.Errorf("%s U+%04X should not be flagged foldable", s.name, r)
break
}
}
}
}
// The byte-view scan must agree with the shipped reference scanners. The interesting inputs
// are runes whose low byte collides with the needle (U+0165 has low byte 'e')
// and runes sharing a lane offset, which the alignment and zero checks reject.
func TestIndexAsciiRuneMatchesReference(t *testing.T) {
rng := rand.New(rand.NewSource(3))
alphabet := []rune{'a', 'A', 'e', 'E', '/', '1', 0x0165, 0x00E9, 0x4E00, 0xD55C,
0x1F389, 0x0065 + 0x100, 0x0041 + 0x100, 0x2F65}
for trial := range 20000 {
n := rng.Intn(24)
runes := make([]rune, n)
for i := range runes {
runes[i] = alphabet[rng.Intn(len(alphabet))]
}
b := []byte{'a', 'e', 'A', 'E', '/', '1'}[rng.Intn(6)]
cs := rng.Intn(2) == 0
from := 0
if n > 0 {
from = rng.Intn(n)
}
if got, exp := indexAsciiRune(runes, cs, b, from), indexAsciiRuneRef(runes, cs, b, from); got != exp {
t.Fatalf("trial %d: indexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
}
if got, exp := lastIndexAsciiRune(runes, cs, b, from), lastIndexAsciiRuneRef(runes, cs, b, from); got != exp {
t.Fatalf("trial %d: lastIndexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
}
}
}
// Differential test: the prefilter must not change any Result or position.
// Corpora deliberately mix scripts that clear the foldable bit (CJK, Hangul,
// Cyrillic, emoji) with scripts that set it (accented Latin, fullwidth), so
// both the engaged and the bypassed path are exercised.
func TestRunePrefilterEquivalence(t *testing.T) {
t.Cleanup(func() { disableRunePrefilter = false })
rng := rand.New(rand.NewSource(4))
parts := []string{
"src", "util", "conf", "a", "e", "E", "A", "/", "_", "1", " ",
"漢字", "한글", "мир", "ελλ", "🎉", "café", "Müller", "naïve", "full",
"Å", "İ", "K", "ǰ", "ff",
}
patterns := []string{"a", "e", "conf", "src/util", "ae", "A", "E", "K", "k", "i", "//", "zz", "s l",
// non-ASCII patterns, the Step G path
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "",
"漢a", "a漢", "한글/src", "🎉e"}
slab := util.MakeSlab(100*1024, 2048)
engaged, bypassed := 0, 0
for trial := range 30000 {
var sb strings.Builder
for range 1 + rng.Intn(8) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() {
continue
}
if chars.MayFoldToAscii() {
bypassed++
} else {
engaged++
}
pat := patterns[rng.Intn(len(patterns))]
cs := rng.Intn(2) == 0
if !cs {
pat = strings.ToLower(pat)
}
pattern := []rune(pat)
norm := rng.Intn(2) == 0
fwd := rng.Intn(2) == 0
wp := rng.Intn(2) == 0
disableRunePrefilter = true
expR, expP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
disableRunePrefilter = false
gotR, gotP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Fatalf("trial %d: %q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
trial, sb.String(), pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
}
}
disableRunePrefilter = false
t.Logf("prefilter engaged on %d items, bypassed on %d", engaged, bypassed)
if engaged == 0 || bypassed == 0 {
t.Fatalf("corpus did not exercise both paths (engaged=%d bypassed=%d)", engaged, bypassed)
}
// Equivalence alone would still hold if the prefilter never filtered
// anything, so confirm it both rejects and narrows.
rejected, narrowed := 0, 0
for range 5000 {
var sb strings.Builder
for range 1 + rng.Intn(8) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() || chars.MayFoldToAscii() {
continue
}
pattern := []rune(patterns[rng.Intn(len(patterns))])
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
switch {
case lo < 0:
rejected++
case hi-lo < chars.Length():
narrowed++
}
}
t.Logf("prefilter rejected %d items, narrowed scope on %d", rejected, narrowed)
if rejected == 0 {
t.Fatal("prefilter never rejected an item, so equivalence proves nothing")
}
if narrowed == 0 {
t.Fatal("prefilter never narrowed the scope")
}
}
// FuzzyMatchV1 shares asciiFuzzyIndex, so it needs the same guarantee.
func TestRunePrefilterEquivalenceV1(t *testing.T) {
t.Cleanup(func() { disableRunePrefilter = false })
rng := rand.New(rand.NewSource(5))
parts := []string{"src", "conf", "a", "e", "/", "漢字", "한글", "мир", "café", "Å", "🎉"}
slab := util.MakeSlab(100*1024, 2048)
for trial := range 20000 {
var sb strings.Builder
for range 1 + rng.Intn(6) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() {
continue
}
pattern := []rune([]string{"a", "e", "conf", "src", "ae", "zz"}[rng.Intn(6)])
cs, norm, fwd, wp := rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0
disableRunePrefilter = true
expR, expP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
disableRunePrefilter = false
gotR, gotP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Fatalf("trial %d: %q pattern=%q\n prefilter on: %v %v\n prefilter off: %v %v",
trial, sb.String(), string(pattern), gotR, gotP, expR, expP)
}
}
disableRunePrefilter = false
}
// normalizeRune skips the map when util.MayFoldToAscii rejects the rune. That
// is only sound if every key of the map is flagged, since a flagged-false rune
// is returned unchanged.
func TestNormalizedKeysAreFlagged(t *testing.T) {
for k := range normalized {
if !util.MayFoldToAscii(k) {
t.Errorf("normalized key U+%04X (%c) is not flagged by MayFoldToAscii", k, k)
}
}
}
// Guarding normalizeRune must not change what it returns, for any rune.
func TestNormalizeRuneUnchangedByGuard(t *testing.T) {
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
exp := r
if r >= 0x00C0 && r <= 0xFF61 {
if n := normalized[r]; n > 0 {
exp = n
}
}
if got := normalizeRune(r); got != exp {
t.Fatalf("normalizeRune(U+%04X) = U+%04X, expected U+%04X", r, got, exp)
}
}
}
// Step G lets non-ASCII pattern runes use the scan, but only when no other
// rune can transform into them. Being uncased is not sufficient: U+00DF has no
// simple uppercase yet U+1E9E lowercases onto it. This checks the guard against
// the full preimage relation over all of Unicode.
func TestRunePrefilterableGuardIsSound(t *testing.T) {
preimage := map[rune][]rune{}
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
if charClassOfNonAscii(r) == charUpper {
if l := unicode.To(unicode.LowerCase, r); l != r {
preimage[l] = append(preimage[l], r)
}
}
}
clean := util.ToChars([]byte("漢字")) // rune mode, fold bit clear
admitted, violations := 0, 0
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
if !runePrefilterable(&clean, []rune{r}, false) {
continue
}
admitted++
if extra := preimage[r]; len(extra) > 0 {
violations++
if violations <= 5 {
t.Errorf("guard admits U+%04X but %U lowercases onto it", r, extra)
}
}
}
t.Logf("guard admits %d non-ASCII pattern runes, unsound for %d", admitted, violations)
// The scripts this step exists for must be fully admitted.
for _, s := range []struct {
name string
lo, hi rune
}{{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
{"Thai", 0x0E00, 0x0E7F}, {"emoji", 0x1F300, 0x1FAFF}} {
for r := s.lo; r <= s.hi; r++ {
if !runePrefilterable(&clean, []rune{r}, false) {
t.Errorf("%s U+%04X should be admitted", s.name, r)
break
}
}
}
}
// The Step G path must actually run and reject, otherwise the equivalence
// test above proves nothing about non-ASCII patterns.
func TestNonAsciiPatternPrefilterEngages(t *testing.T) {
rng := rand.New(rand.NewSource(6))
parts := []string{"漢字", "한글", "src", "conf", "/", "мир", "🎉"}
engaged, rejected, narrowed, bypassed := 0, 0, 0, 0
for range 5000 {
var sb strings.Builder
for range 1 + rng.Intn(6) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() {
continue
}
pattern := []rune([]string{"漢", "漢字", "한글", "мир", "🎉", "é", "ß"}[rng.Intn(7)])
if !runePrefilterable(&chars, pattern, false) {
bypassed++
continue
}
engaged++
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
switch {
case lo < 0:
rejected++
case hi-lo < chars.Length():
narrowed++
}
}
t.Logf("non-ASCII patterns: engaged %d, bypassed %d, rejected %d, narrowed %d",
engaged, bypassed, rejected, narrowed)
if engaged == 0 || rejected == 0 {
t.Fatalf("non-ASCII pattern path not exercised (engaged=%d rejected=%d)", engaged, rejected)
}
if bypassed == 0 {
t.Fatal("cased and foldable patterns should still bypass")
}
}
// indexRune picks which byte lane to scan, and is checked against the shipped
// reference scanners rather than a copy of them. Zero-low-byte runes (U+AE00) and
// runes whose lanes collide with common ASCII bytes are the cases that break a
// naive low-byte scan, so the alphabet includes both.
func TestIndexRuneMatchesReference(t *testing.T) {
alphabet := []rune{
'a', 'e', 'N', '/', 0x00,
0xAE00, 0xAC00, 0xD55C, // Hangul, low byte zero for U+AE00
0x4E00, 0x6587, 0x9FFF, // CJK, U+4E00 has zero low byte
0x3040, 0x0E00, // kana, Thai with zero low byte
0x1F389, 0x1F300, // emoji, 3 significant bytes
0x0100, 0x0165, 0x00E9, // low byte zero / ASCII-colliding lanes
}
rng := rand.New(rand.NewSource(7))
for trial := range 30000 {
n := rng.Intn(20)
runes := make([]rune, n)
for i := range runes {
runes[i] = alphabet[rng.Intn(len(alphabet))]
}
r := alphabet[rng.Intn(len(alphabet))]
from := 0
if n > 0 {
from = rng.Intn(n)
}
if got, exp := indexRune(runes, r, from), indexRuneRef(runes, r, from); got != exp {
t.Fatalf("trial %d: indexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
}
if got, exp := lastIndexRune(runes, r, from), lastIndexRuneRef(runes, r, from); got != exp {
t.Fatalf("trial %d: lastIndexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
}
}
}
// preparePattern mirrors what pattern.go guarantees the algo functions:
// lowercased when case-insensitive, normalized when normalize is on.
func preparePattern(pat string, caseSensitive, normalize bool) []rune {
if !caseSensitive {
pat = strings.ToLower(pat)
}
r := []rune(pat)
if normalize {
r = NormalizeRunes(r)
}
return r
}
// FuzzRunePrefilter drives arbitrary rune-mode input and arbitrary patterns
// through the prefilter and through the same code with it disabled, and
// requires identical Results and positions. The existing fast-path fuzzers
// only generate byte-mode input, so they never reach this path.
func FuzzRunePrefilter(f *testing.F) {
for _, in := range []string{
"한글/src/util.go", "漢字/conf", "café/binutils", "мир/test", "🎉/a",
"ABC.txt", "Ångström", "ǰ/ß/İ", "a漢b한c", "Āā",
} {
for _, p := range []string{"a", "conf", "漢", "한글", "мир", "ß", "É", "a漢"} {
f.Add(in, p)
}
}
slab := util.MakeSlab(200*1024, 4096)
f.Fuzz(func(t *testing.T, input, pat string) {
if len(input) > 512 || len(pat) == 0 || len(pat) > 32 {
return
}
chars := util.ToChars([]byte(input))
if chars.IsBytes() {
return // byte mode is covered by the existing fuzzers
}
for _, cs := range []bool{false, true} {
for _, norm := range []bool{false, true} {
p := preparePattern(pat, cs, norm)
if len(p) == 0 {
continue
}
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive} {
disableRunePrefilter = true
expR, expP := fn(cs, norm, fwd, &chars, p, wp, slab)
disableRunePrefilter = false
gotR, gotP := fn(cs, norm, fwd, &chars, p, wp, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Fatalf("input=%q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
input, pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
}
}
}
}
}
}
})
}
// RunesToChars can produce rune-mode Chars holding zero runes, which sends a
// nil pointer through unsafe.SliceData in runeBytes. ToChars cannot produce
// this (an empty input is byte mode), so it needs its own test.
func TestEmptyRuneModeChars(t *testing.T) {
t.Cleanup(func() { disableRunePrefilter = false })
slab := util.MakeSlab(100*1024, 2048)
for _, runes := range [][]rune{{}, nil, {'a'}, {0x4E00}} {
chars := util.RunesToChars(runes)
if chars.IsBytes() {
continue
}
for _, pat := range []string{"a", "漢", "ab"} {
p := []rune(pat)
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive,
PrefixMatch, SuffixMatch, EqualMatch} {
disableRunePrefilter = true
expR, expP := fn(false, true, true, &chars, p, true, slab)
disableRunePrefilter = false
gotR, gotP := fn(false, true, true, &chars, p, true, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Errorf("runes=%U pat=%q: prefilter on %v %v, off %v %v", runes, pat, gotR, gotP, expR, expP)
}
}
}
}
}
// MayFoldToAscii subtracts before bounds-checking, so a rune below the range
// (including a negative one, which utf8 decoding never produces but callers
// could construct) must not wrap into a false positive.
func TestMayFoldToAsciiOutOfRange(t *testing.T) {
for _, r := range []rune{-1, -0x10000, 0, 'a', 0x7F, 0xBF, 0xFF62, unicode.MaxRune, unicode.MaxRune + 1} {
if util.MayFoldToAscii(r) {
t.Errorf("MayFoldToAscii(%d) = true, expected false", r)
}
}
}
+2 -52
View File
@@ -58,7 +58,6 @@ var offsetTrimCharsRegex *regexp.Regexp
var passThroughBeginRegex *regexp.Regexp var passThroughBeginRegex *regexp.Regexp
var passThroughEndTmuxRegex *regexp.Regexp var passThroughEndTmuxRegex *regexp.Regexp
var sixelBeginRegex *regexp.Regexp var sixelBeginRegex *regexp.Regexp
var cursorBackRegex *regexp.Regexp
var ttyin *os.File var ttyin *os.File
var inTmux = len(os.Getenv("TMUX")) > 0 var inTmux = len(os.Getenv("TMUX")) > 0
@@ -97,9 +96,6 @@ func init() {
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`) passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`) passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`) sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`)
// CUB right before an IND, used to return to the column a row started on
cursorBackRegex = regexp.MustCompile(`\x1b\[([0-9]*)D$`)
} }
type jumpMode int type jumpMode int
@@ -2280,14 +2276,6 @@ func (t *Terminal) displayWidthWithPrefix(str string, prefixWidth int) int {
return width return width
} }
// displayWidthWithoutEscapes is displayWidthWithPrefix for a string that may
// still carry pass-throughs and ANSI codes, neither of which take any column.
func (t *Terminal) displayWidthWithoutEscapes(str string, prefixWidth int) int {
_, text := extractPassThroughs(str)
stripped, _, _ := extractColor(text, nil, nil)
return t.displayWidthWithPrefix(stripped, prefixWidth)
}
const ( const (
minWidth = 4 minWidth = 4
minHeight = 3 minHeight = 3
@@ -4861,38 +4849,6 @@ func extractPassThroughs(line string) ([]string, string) {
return passThroughs, transformed return passThroughs, transformed
} }
// splitOnIND breaks a preview line on IND (ESC D), which moves the cursor
// down one line, keeping the column. A program drawing at a column offset ends
// its rows with IND instead of a newline, because ONLCR would rewrite a newline
// as CR NL and snap the cursor to column 0. chafa does this for Kitty Unicode
// placeholders, and without the break the whole image collapses into a single
// line.
//
// The column is tracked and re-created with padding so that an indented image
// keeps its indent, and a CUB right before the IND is subtracted, which is how
// chafa returns to the column its rows start on.
func (t *Terminal) splitOnIND(line string) []string {
chunks := strings.Split(line, "\x1bD")
if len(chunks) == 1 {
return nil
}
lines := make([]string, 0, len(chunks))
col := 0
for _, chunk := range chunks[:len(chunks)-1] {
lines = append(lines, strings.Repeat(" ", col)+chunk+"\n")
col += t.displayWidthWithoutEscapes(chunk, col)
if match := cursorBackRegex.FindStringSubmatch(chunk); match != nil {
back := 1
if len(match[1]) > 0 {
back, _ = strconv.Atoi(match[1])
}
col = max(0, col-back)
}
}
return append(lines, strings.Repeat(" ", col)+chunks[len(chunks)-1])
}
// followOffset computes the correct content-line offset for follow mode, // followOffset computes the correct content-line offset for follow mode,
// accounting for line wrapping in the preview window. // accounting for line wrapping in the preview window.
func (t *Terminal) followOffset() int { func (t *Terminal) followOffset() int {
@@ -6465,11 +6421,7 @@ func (t *Terminal) Loop() error {
version-- version--
offset = 0 offset = 0
} }
if split := t.splitOnIND(line); split != nil { lines = append(lines, line)
lines = append(lines, split...)
} else {
lines = append(lines, line)
}
} }
if err != nil { if err != nil {
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""}) t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
@@ -7417,9 +7369,7 @@ func (t *Terminal) Loop() error {
case actReplaceQuery: case actReplaceQuery:
current := t.currentItem() current := t.currentItem()
if current != nil { if current != nil {
// ToRunes aliases the item text in rune mode, and the t.input = current.text.ToRunes()
// editing actions below append into t.input in place
t.input = append([]rune{}, current.text.ToRunes()...)
t.cx = len(t.input) t.cx = len(t.input)
} }
case actFatal: case actFatal:
+8 -78
View File
@@ -223,7 +223,7 @@ func TestReplacePlaceholder(t *testing.T) {
// while the double q is invalid, it is useful here for testing purposes // while the double q is invalid, it is useful here for testing purposes
templateToOutput[`{q}`] = "{{.O}}" + query + "{{.O}}" templateToOutput[`{q}`] = "{{.O}}" + query + "{{.O}}"
templateToOutput[`{fzf:query}`] = "{{.O}}" + query + "{{.O}}" templateToOutput[`{fzf:query}`] = "{{.O}}" + query + "{{.O}}"
templateToOutput[`{fzf:action} {fzf:prompt}`] = `backward-delete-char-eof {{.O}}prompt{{.O}}` templateToOutput[`{fzf:action} {fzf:prompt}`] = "backward-delete-char-eof 'prompt'"
// IV. escaping placeholder // IV. escaping placeholder
templateToOutput[`\{}`] = `{}` templateToOutput[`\{}`] = `{}`
@@ -251,9 +251,9 @@ func TestReplacePlaceholder(t *testing.T) {
} }
func TestQuoteEntry(t *testing.T) { func TestQuoteEntry(t *testing.T) {
type quotes struct{ E, O, SQ, DQ, BS, PB string } // standalone escape, outer, single and double quotes, doubled and plain backslash type quotes struct{ E, O, SQ, DQ, BS string } // standalone escape, outer, single and double quotes, backslash
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`, `\`} unixStyle := quotes{``, `'`, `'\''`, `"`, `\`}
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`, `\`} windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`}
var effectiveStyle quotes var effectiveStyle quotes
exec := util.NewExecutor("") 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}}{{.E}}){{.O}}`, `)`: `{{.O}}{{.E}}){{.O}}`,
`@`: `{{.O}}@{{.O}}`, `@`: `{{.O}}{{.E}}@{{.O}}`,
`^`: `{{.O}}{{.E}}^{{.O}}`, `^`: `{{.O}}{{.E}}^{{.O}}`,
`%`: `{{.O}}{{.E}}%{{.O}}`, `%`: `{{.O}}{{.E}}%{{.O}}`,
`!`: `{{.O}}{{.E}}!{{.O}}`, `!`: `{{.O}}{{.E}}!{{.O}}`,
`%USERPROFILE%`: `{{.O}}{{.E}}%USERPROFILE{{.E}}%{{.O}}`, `%USERPROFILE%`: `{{.O}}{{.E}}%USERPROFILE{{.E}}%{{.O}}`,
`C:\Program Files (x86)\`: `{{.O}}C:{{.PB}}Program Files {{.E}}(x86{{.E}}){{.BS}}{{.O}}`, `C:\Program Files (x86)\`: `{{.O}}C:{{.BS}}Program Files {{.E}}(x86{{.E}}){{.BS}}{{.O}}`,
`"C:\Program Files"`: `{{.O}}{{.DQ}}C:{{.PB}}Program Files{{.DQ}}{{.O}}`, `"C:\Program Files"`: `{{.O}}{{.DQ}}C:{{.BS}}Program Files{{.DQ}}{{.O}}`,
} }
for input, expected := range tests { 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 // to explorer, which will prompt user to pick editing program for the fzf-preview file
// the temp file contains: `cat "C:\test.txt"` // the temp file contains: `cat "C:\test.txt"`
// TODO this should actually work // TODO this should actually work
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-temp-[0-9]+$`}}, {give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-preview-[0-9]{9}$`}},
} }
// to force powershell-style escaping we temporarily set environment variable that fzf honors // to force powershell-style escaping we temporarily set environment variable that fzf honors
@@ -851,73 +851,3 @@ func TestWordWrapAnsiLine(t *testing.T) {
t.Errorf("Tab wrap: %q", result) t.Errorf("Tab wrap: %q", result)
} }
} }
func TestSplitOnIND(t *testing.T) {
term := &Terminal{tabstop: 8}
for _, tc := range []struct {
name string
line string
want []string
}{
{
// Nothing to do, so the caller keeps the line as it read it
name: "no IND",
line: "foo\n",
want: nil,
},
{
// chafa: CUB returns to the column the row started on
name: "rows at column 0",
line: "AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{"AAA\x1b[3D\n", "BBB\x1b[3D\n", "CCC\n"},
},
{
// 'printf " "; chafa ...'
name: "indented rows",
line: " AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{" AAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
},
{
name: "tab indent expands to the tab stop",
line: "\tAAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{"\tAAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
},
{
// IND on its own keeps the column
name: "bare IND",
line: "AB\x1bDCD\n",
want: []string{"AB\n", " CD\n"},
},
{
name: "CUB without a parameter moves back one",
line: "AB\x1b[D\x1bDCD\n",
want: []string{"AB\x1b[D\n", " CD\n"},
},
{
name: "CUB past the left edge is clamped",
line: "AB\x1b[9D\x1bDCD\n",
want: []string{"AB\x1b[9D\n", "CD\n"},
},
{
name: "SGR codes take no column",
line: "\x1b[31mAB\x1b[m\x1b[2D\x1bDCD\n",
want: []string{"\x1b[31mAB\x1b[m\x1b[2D\n", "CD\n"},
},
{
name: "pass-throughs take no column",
line: "\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\x1bDCD\n",
want: []string{"\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\n", "CD\n"},
},
} {
got := term.splitOnIND(tc.line)
if len(got) != len(tc.want) {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
continue
}
for idx, line := range got {
if line != tc.want[idx] {
t.Errorf("%s: line %d: got %q, want %q", tc.name, idx, line, tc.want[idx])
}
}
}
}
+5 -62
View File
@@ -24,44 +24,15 @@ const (
defaultEscDelay = 100 defaultEscDelay = 100
escPollInterval = 5 escPollInterval = 5
offsetPollTries = 10 offsetPollTries = 10
queryTimeout = 500 * time.Millisecond
maxInputBuffer = 1024 * 1024 maxInputBuffer = 1024 * 1024
maxSelectTries = 100 maxSelectTries = 100
) )
const DefaultTtyDevice string = "/dev/tty" 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") 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() { func (r *LightRenderer) Bell() {
r.flushRaw("\a") r.flushRaw("\a")
} }
@@ -187,10 +158,6 @@ type LightRenderer struct {
showCursor bool showCursor bool
mutex sync.Mutex 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 // Windows only
ttyinChannel chan byte ttyinChannel chan byte
inHandle uintptr inHandle uintptr
@@ -263,13 +230,8 @@ func (r *LightRenderer) Init() error {
if r.fullscreen { if r.fullscreen {
r.smcup() 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 r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small // When --no-clear is used for repetitive relaunching, there is a small
// time frame between fzf processes where the user keystrokes are not // time frame between fzf processes where the user keystrokes are not
@@ -356,11 +318,7 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
if c == Esc.Int() || nonblock { if c == Esc.Int() || nonblock {
retries = r.escDelay / escPollInterval retries = r.escDelay / escPollInterval
} }
// A non-blocking read that found nothing has no byte to record. Recording buffer = append(buffer, byte(c))
// one would put a NUL in the middle of a reply still being assembled.
if result.ok() {
buffer = append(buffer, byte(c))
}
pc := c pc := c
for { for {
@@ -488,12 +446,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{Invalid, 0, nil} 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 *sz = 2
if r.buffer[1] == 8 { if r.buffer[1] == 8 {
return Event{CtrlAltBackspace, 0, nil} return Event{CtrlAltBackspace, 0, nil}
@@ -1067,16 +1019,7 @@ func (r *LightRenderer) disableMouse() {
func (r *LightRenderer) disableModes() { func (r *LightRenderer) disableModes() {
r.disableMouse() r.disableMouse()
// Put bracketed paste back the way we found it. A shell that runs fzf from r.csi("?2004l")
// 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) { func (r *LightRenderer) Resume(clear bool, sigcont bool) {
-128
View File
@@ -1,128 +0,0 @@
//go:build !windows
package tui
import (
"os"
"strings"
"testing"
)
// Drives queryStartup against a terminal simulated by pipes, with the replies
// already queued so the exchange is deterministic.
func replyingTerminal(t *testing.T, replies string) (*LightRenderer, func() string) {
t.Helper()
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
outR, outW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
if _, err := inW.WriteString(replies); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { inR.Close(); inW.Close(); outR.Close() })
r := &LightRenderer{ttyin: inR, ttyout: outW, escDelay: defaultEscDelay}
written := func() string {
outW.Close()
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := outR.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
return sb.String()
}
return r, written
}
const eraseEcho = "\b \b"
func TestQueryStartup(t *testing.T) {
for _, tc := range []struct {
name string
replies string
row, col int
paste string // "", "true" or "false"
erase int // columns the echo took, and so erasures expected
}{
{
name: "mode already set",
replies: "\x1b[5;10R\x1b[?2004;1$y\x1b[5;10R",
row: 4, col: 9, paste: "true",
},
{
name: "mode reset",
replies: "\x1b[5;10R\x1b[?2004;2$y\x1b[5;10R",
row: 4, col: 9, paste: "false",
},
{
name: "mode not recognized",
replies: "\x1b[5;10R\x1b[?2004;0$y\x1b[5;10R",
row: 4, col: 9,
},
{
// Answers the position queries but ignores DECRQM without printing
name: "query ignored",
replies: "\x1b[5;10R\x1b[5;10R",
row: 4, col: 9,
},
{
// macOS Terminal.app: ends the sequence at '$' and prints the 'p'
name: "query echoed",
replies: "\x1b[5;10R\x1b[5;11R",
row: 4, col: 9, erase: 1,
},
{
// A terminal that prints more of what it could not parse
name: "longer echo",
replies: "\x1b[5;10R\x1b[5;13R",
row: 4, col: 9, erase: 3,
},
{
// The echo wrapped to the next line, where erasing would guess
name: "echo wrapped",
replies: "\x1b[5;80R\x1b[6;1R",
row: 4, col: 79,
},
{
name: "no reply at all",
replies: "",
row: -1, col: -1,
},
} {
t.Run(tc.name, func(t *testing.T) {
r, written := replyingTerminal(t, tc.replies)
row, col, pasteWasSet := r.queryStartup()
if row != tc.row || col != tc.col {
t.Errorf("offset (%d,%d), want (%d,%d)", row, col, tc.row, tc.col)
}
paste := ""
if pasteWasSet != nil {
paste = "false"
if *pasteWasSet {
paste = "true"
}
}
if paste != tc.paste {
t.Errorf("pasteWasSet %q, want %q", paste, tc.paste)
}
out := written()
if got := strings.Count(out, eraseEcho); got != tc.erase {
t.Errorf("erased %d columns, want %d (wrote %q)", got, tc.erase, out)
}
// The paste mode query has to sit between two position queries
if want := "\x1b[6n\x1b[?2004$p\x1b[6n"; !strings.Contains(out, want) {
t.Errorf("queries %q, want %q in order", out, want)
}
})
}
}
-1
View File
@@ -434,7 +434,6 @@ func TestLightRendererSGRDeduplication(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer out.Close()
r.ttyout = out r.ttyout = out
w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow) w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow)
+18 -114
View File
@@ -8,7 +8,6 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
@@ -94,120 +93,25 @@ func (r *LightRenderer) updateTerminalSize() {
} }
} }
// waitReadable reports whether the tty has something to read before the
// deadline. A terminal that does not recognize a query answers nothing at all,
// so the read that follows must be able to stop waiting, or fzf would wait for
// the user to press a key instead of drawing itself. The timeout is generous
// because a terminal that does answer exceeds it only when the link is slow
// enough to be unusable anyway.
func (r *LightRenderer) waitReadable(timeout time.Duration) bool {
fd := r.fd()
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
var rfds unix.FdSet
if fd >= len(rfds.Bits)*unix.NFDBITS {
return false
}
rfds.Set(fd)
// Recomputed each time round: Linux select rewrites the timeout with
// the time left, other systems leave it alone
tv := unix.NsecToTimeval(int64(remaining))
n, err := unix.Select(fd+1, &rfds, nil, nil, &tv)
if err == syscall.EINTR {
continue
}
if err != nil {
// Nothing was confirmed readable, and the read that follows
// reports the failure if the fd is really broken
return false
}
return n > 0
}
}
// queryTerminal sends every query in a single write and reads until the last
// one is answered. Returns the submatches of each reply, nil for a query the
// terminal ignored. Replies are cut out of what we read as they are recognized,
// so whatever is left over is input the user typed during the round trip.
func (r *LightRenderer) queryTerminal(queries []termQuery) [][][]byte {
for _, query := range queries {
r.csi(query.seq)
}
r.flush()
replies := make([][][]byte, len(queries))
buffer := []byte{}
for tries := range offsetPollTries {
// Only the first read blocks, so that is the one to put a bound on
if tries == 0 && !r.waitReadable(queryTimeout) {
return replies
}
var err error
buffer, _, err = r.getBytesInternal(false, buffer, tries > 0)
if err != nil {
return replies
}
for idx, query := range queries {
if replies[idx] != nil {
continue
}
loc := query.reply.FindSubmatchIndex(buffer)
if loc == nil {
continue
}
groups := make([][]byte, len(loc)/2)
for group := range groups {
if loc[group*2] >= 0 {
groups[group] = buffer[loc[group*2]:loc[group*2+1]]
}
}
replies[idx] = groups
// Capping the prefix makes append copy, leaving groups valid
buffer = append(buffer[:loc[0]:loc[0]], buffer[loc[1]:]...)
}
if replies[len(queries)-1] != nil {
break
}
}
r.buffer = append(r.buffer, buffer...)
return replies
}
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
replies := r.queryTerminal(startupQueries)
before, paste, after := replies[0], replies[1], replies[2]
if paste != nil && paste[1][0] != '0' {
// 1 = set, 3 = permanently set
set := paste[1][0] == '1' || paste[1][0] == '3'
pasteWasSet = &set
}
row, col = parseOffset(before)
// The cursor moved right likely because terminal doesn't support DECRQM
if row2, col2 := parseOffset(after); row >= 0 && row2 == row && col2 > col {
r.flushRaw(strings.Repeat("\b \b", col2-col))
}
return
}
func parseOffset(reply [][]byte) (row int, col int) {
if reply == nil {
return -1, -1
}
return atoi(string(reply[1]), 0) - 1, atoi(string(reply[2]), 0) - 1
}
func (r *LightRenderer) findOffset() (row int, col int) { func (r *LightRenderer) findOffset() (row int, col int) {
return parseOffset(r.queryTerminal([]termQuery{offsetQuery})[0]) r.csi("6n")
r.flush()
var err error
bytes := []byte{}
for tries := range offsetPollTries {
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
if err != nil {
return -1, -1
}
offsets := offsetRegexp.FindSubmatch(bytes)
if len(offsets) > 3 {
// Add anything we skipped over to the input buffer
r.buffer = append(r.buffer, offsets[1]...)
return atoi(string(offsets[2]), 0) - 1, atoi(string(offsets[3]), 0) - 1
}
}
return -1, -1
} }
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) { func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
-7
View File
@@ -151,13 +151,6 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X) 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) { func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
if !nonblock && !cancellable { if !nonblock && !cancellable {
bc := <-r.ttyinChannel bc := <-r.ttyinChannel
+9 -142
View File
@@ -3,7 +3,6 @@ package util
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"math/bits"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"unsafe" "unsafe"
@@ -14,17 +13,9 @@ const (
overflow32 uint32 = 0x80808080 overflow32 uint32 = 0x80808080
) )
const (
flagInBytes uint8 = 1 << iota
flagMayFold
)
type Chars struct { type Chars struct {
slice []byte // or []rune slice []byte // or []rune
// Only ever set, never cleared, so a reader racing a Prepend sees either inBytes bool
// the old or the new value and both are safe. trimLength* is kept out
// because TrimLength rewrites it.
flags uint8
trimLengthKnown bool trimLengthKnown bool
trimLength uint16 trimLength uint16
@@ -33,59 +24,6 @@ type Chars struct {
Index int32 Index int32
} }
// Rune ranges that case folding or normalization can turn into ASCII, derived
// from algo's normalization table and unicode.ToLower, then merged. They are a
// superset of the exact set, which TestMayFoldToAsciiIsSuperset in the algo
// package 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) { func checkAscii(bytes []byte) (bool, int) {
i := 0 i := 0
for ; i <= len(bytes)-8; i += 8 { for ; i <= len(bytes)-8; i += 8 {
@@ -106,94 +44,31 @@ func checkAscii(bytes []byte) (bool, int) {
return true, 0 return true, 0
} }
// countRunes counts the bytes that are not UTF-8 continuation bytes, which is
// the rune count of valid UTF-8. Each invalid byte decodes to its own
// RuneError, so the result can undercount but never overcount, making it safe
// as a capacity hint.
func countRunes(bytes []byte) int {
n, i := 0, 0
for ; i <= len(bytes)-8; i += 8 {
v := *(*uint64)(unsafe.Pointer(&bytes[i]))
// Continuation byte: bit 7 set, bit 6 clear. In `v << 1` bit 7 of each
// lane holds bit 6 of that same lane.
n += 8 - bits.OnesCount64(v&^(v<<1)&overflow64)
}
for ; i < len(bytes); i++ {
if bytes[i]&0xC0 != 0x80 {
n++
}
}
return n
}
// ToChars converts byte array into rune array // ToChars converts byte array into rune array
func ToChars(bytes []byte) Chars { func ToChars(bytes []byte) Chars {
inBytes, bytesUntil := checkAscii(bytes) inBytes, bytesUntil := checkAscii(bytes)
if inBytes { if inBytes {
return Chars{slice: bytes, flags: flagInBytes} return Chars{slice: bytes, inBytes: inBytes}
} }
runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:])) runes := make([]rune, bytesUntil, len(bytes))
for i := range bytesUntil { for i := range bytesUntil {
runes[i] = rune(bytes[i]) runes[i] = rune(bytes[i])
} }
mayFold := false
for i := bytesUntil; i < len(bytes); { for i := bytesUntil; i < len(bytes); {
// utf8.DecodeRune has an ASCII path of its own, but it is too complex
// to inline, so a mostly-ASCII line pays one call per byte for it.
// An ASCII rune never sets the fold bit either, so skip both calls.
if b := bytes[i]; b < utf8.RuneSelf {
runes = append(runes, rune(b))
i++
continue
}
r, sz := utf8.DecodeRune(bytes[i:]) r, sz := utf8.DecodeRune(bytes[i:])
i += sz i += sz
mayFold = mayFold || MayFoldToAscii(r)
runes = append(runes, r) runes = append(runes, r)
} }
return runesToChars(runes, mayFold) return RunesToChars(runes)
} }
// RunesToChars adopts the caller's slice rather than copying it, so the caller
// must not keep mutating it. See Runes for why.
func RunesToChars(runes []rune) Chars { func RunesToChars(runes []rune) Chars {
mayFold := false return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false}
for _, r := range runes {
if MayFoldToAscii(r) {
mayFold = true
break
}
}
return runesToChars(runes, mayFold)
}
func runesToChars(runes []rune, mayFold bool) Chars {
var flags uint8
if mayFold {
flags = flagMayFold
}
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), flags: flags}
} }
func (chars *Chars) IsBytes() bool { func (chars *Chars) IsBytes() bool {
return chars.flags&flagInBytes != 0 return chars.inBytes
}
// MayFoldToAscii reports whether the text holds a rune that case folding or
// normalization could turn into an ASCII character. When false, an ASCII
// pattern character can only match the identical ASCII rune, which is what
// lets the prefilter scan the rune array directly.
func (chars *Chars) MayFoldToAscii() bool {
return chars.flags&flagMayFold != 0
}
// Runes returns the underlying rune slice, or nil if the text is kept as
// bytes. Read only. The result aliases the text, so writing to it would change
// the text without updating the cached fold bit, and the prefilter would then
// reject items it should match. Copy before mutating.
func (chars *Chars) Runes() []rune {
return chars.optionalRunes()
} }
func (chars *Chars) Bytes() []byte { func (chars *Chars) Bytes() []byte {
@@ -230,7 +105,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) {
} }
func (chars *Chars) optionalRunes() []rune { func (chars *Chars) optionalRunes() []rune {
if chars.IsBytes() { if chars.inBytes {
return nil return nil
} }
return *(*[]rune)(unsafe.Pointer(&chars.slice)) return *(*[]rune)(unsafe.Pointer(&chars.slice))
@@ -252,7 +127,7 @@ func (chars *Chars) Length() int {
// String returns the string representation of a Chars object. // String returns the string representation of a Chars object.
func (chars *Chars) String() string { func (chars *Chars) String() string {
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, mayFold: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.IsBytes(), chars.MayFoldToAscii(), chars.trimLengthKnown, chars.trimLength, chars.Index) return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.inBytes, chars.trimLengthKnown, chars.trimLength, chars.Index)
} }
// TrimLength returns the length after trimming leading and trailing whitespaces // TrimLength returns the length after trimming leading and trailing whitespaces
@@ -343,8 +218,6 @@ func (chars *Chars) ToString() string {
return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice)) return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice))
} }
// ToRunes returns the text as runes. In rune mode the result aliases the text
// and must not be mutated, see Runes. In byte mode it is a fresh slice.
func (chars *Chars) ToRunes() []rune { func (chars *Chars) ToRunes() []rune {
if runes := chars.optionalRunes(); runes != nil { if runes := chars.optionalRunes(); runes != nil {
return runes return runes
@@ -374,12 +247,6 @@ func (chars *Chars) Prepend(prefix string) {
} else { } else {
chars.slice = append([]byte(prefix), chars.slice...) chars.slice = append([]byte(prefix), chars.slice...)
} }
for _, r := range prefix {
if MayFoldToAscii(r) {
chars.flags |= flagMayFold
break
}
}
} }
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) { func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
+20 -162
View File
@@ -2,106 +2,19 @@ package util
import ( import (
"fmt" "fmt"
"math/rand"
"strings"
"testing" "testing"
"unicode/utf8"
"unsafe"
) )
func TestCountRunes(t *testing.T) {
for _, str := range []string{
"", "a", "abc", "한글", "🎉🎉", "\tabc한글 ",
strings.Repeat("漢字", 50), strings.Repeat("a", 33) + "é",
} {
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
t.Errorf("countRunes(%q) = %d, expected %d", str, got, exp)
}
}
}
func TestCountRunesRandom(t *testing.T) {
rng := rand.New(rand.NewSource(1))
// Exact on valid UTF-8
for trial := range 20000 {
var sb strings.Builder
for range rng.Intn(20) {
r := rune(rng.Intn(utf8.MaxRune + 1))
for r >= 0xD800 && r <= 0xDFFF {
r = rune(rng.Intn(utf8.MaxRune + 1))
}
sb.WriteRune(r)
}
str := sb.String()
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
t.Fatalf("trial %d: countRunes(%q) = %d, expected %d", trial, str, got, exp)
}
}
// Never an overcount on arbitrary bytes, so the capacity hint never truncates
for trial := range 20000 {
buf := make([]byte, rng.Intn(40))
rng.Read(buf)
if got, exp := countRunes(buf), utf8.RuneCount(buf); got > exp {
t.Fatalf("trial %d: countRunes(%x) = %d, overcounts %d", trial, buf, got, exp)
}
}
}
// ToChars must produce exactly what a []rune conversion produces, including
// one RuneError per invalid byte, and must size the rune slice exactly when
// the input is valid UTF-8.
func TestToCharsIntegrity(t *testing.T) {
rng := rand.New(rand.NewSource(2))
check := func(buf []byte, exactCap bool) {
chars := ToChars(buf)
exp := []rune(string(buf))
if chars.Length() != len(exp) {
t.Fatalf("ToChars(%x).Length() = %d, expected %d", buf, chars.Length(), len(exp))
}
for i, r := range exp {
if chars.Get(i) != r {
t.Fatalf("ToChars(%x).Get(%d) = %q, expected %q", buf, i, chars.Get(i), r)
}
}
if runes := chars.optionalRunes(); runes != nil && exactCap && cap(runes) != len(exp) {
t.Fatalf("ToChars(%x) cap = %d, expected %d", buf, cap(runes), len(exp))
}
}
for range 5000 {
var sb strings.Builder
sb.WriteString("ascii")
for range 1 + rng.Intn(10) {
r := rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
for r >= 0xD800 && r <= 0xDFFF {
r = rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
}
sb.WriteRune(r)
}
check([]byte(sb.String()), true)
}
// Invalid UTF-8: still correct, capacity may grow
for range 5000 {
buf := make([]byte, 1+rng.Intn(40))
rng.Read(buf)
buf[rng.Intn(len(buf))] |= 0x80 // force the rune path
check(buf, false)
}
}
func TestToCharsAscii(t *testing.T) { func TestToCharsAscii(t *testing.T) {
chars := ToChars([]byte("foobar")) chars := ToChars([]byte("foobar"))
if !chars.IsBytes() || chars.ToString() != "foobar" { if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes {
t.Error() t.Error()
} }
} }
func TestCharsLength(t *testing.T) { func TestCharsLength(t *testing.T) {
chars := ToChars([]byte("\tabc한글 ")) chars := ToChars([]byte("\tabc한글 "))
if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 { if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 {
t.Error() t.Error()
} }
} }
@@ -194,80 +107,25 @@ func TestCharsLinesWrapWord(t *testing.T) {
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0])) 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 // wrapWord=false still character-wraps
chars3 := ToChars([]byte("hello world")) chars4 := ToChars([]byte("hello world"))
lines3, _ := chars3.Lines(false, 100, 8, 0, 8, false) lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
if len(lines3) != 2 { if len(lines4) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines3), lines3) t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
} }
if string(lines3[0]) != "hello wo" { if string(lines4[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines3[0])) t.Errorf("Expected first line 'hello wo', got %q", string(lines4[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)
} }
} }
-13
View File
@@ -479,19 +479,6 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal '> 10', lines[-1] } tmux.until { |lines| assert_equal '> 10', lines[-1] }
end end
def test_bind_replace_query_does_not_mutate_item
tmux.send_keys "echo '한글abcde' | #{fzf('--bind=ctrl-j:replace-query,ctrl-o:clear-query')}", :Enter
tmux.until { |lines| assert_equal ' 1/1', lines[-2] }
tmux.send_keys 'C-j'
tmux.until { |lines| assert_equal '> 한글abcde', lines[-1] }
# Editing away from the end used to write into the item itself
tmux.send_keys :Left, :BSpace
tmux.until { |lines| assert_equal '> 한글abce', lines[-1] }
tmux.send_keys 'C-o'
tmux.until { |lines| assert_equal '>', lines[-1] }
tmux.until { |lines| assert_equal '> 한글abcde', lines[-3] }
end
def test_select_all_deselect_all_toggle_all def test_select_all_deselect_all_toggle_all
tmux.send_keys "seq 100 | #{fzf('--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi')}", :Enter tmux.send_keys "seq 100 | #{fzf('--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi')}", :Enter
tmux.until { |lines| assert_equal ' 100/100 (0)', lines[-2] } tmux.until { |lines| assert_equal ' 100/100 (0)', lines[-2] }
+1 -1
View File
@@ -973,7 +973,7 @@ class TestZsh < TestBase
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until do |lines| 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
end end