Skip to content

Commit cb48ea7

Browse files
authored
Merge pull request #1157 from VXNCXNX:fix/case-insensitive-search-offsets
fix: case-insensitive search highlights the wrong bytes
2 parents 92d0cf3 + 42b9874 commit cb48ea7

2 files changed

Lines changed: 88 additions & 3 deletions

File tree

oviewer/search.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414
"sync/atomic"
1515
"unicode"
16+
"unicode/utf8"
1617

1718
"codeberg.org/tslocum/cbind"
1819
"github.com/gdamore/tcell/v3"
@@ -50,9 +51,51 @@ func (substr searchWord) MatchString(target string) bool {
5051
}
5152

5253
// searchWord FindAll searches for strings and returns the index of the match.
54+
// The returned indexes are positions in target, not in its lowercased form,
55+
// because lowercasing can change the byte length of a rune (İ, K, ẞ...).
5356
func (substr searchWord) FindAll(target string) [][]int {
54-
target = strings.ToLower(target)
55-
return allStringIndex(target, substr.word)
57+
lower, offsets := toLowerWithOffsets(target)
58+
indexes := allStringIndex(lower, substr.word)
59+
if offsets == nil {
60+
return indexes
61+
}
62+
for _, idx := range indexes {
63+
idx[0] = offsets[idx[0]]
64+
idx[1] = offsets[idx[1]]
65+
}
66+
return indexes
67+
}
68+
69+
// toLowerWithOffsets lowercases s the same way [strings.ToLower] does and also
70+
// returns a table that maps every byte offset of the result back to a byte
71+
// offset of s. The table is nil when s is ASCII only, since the offsets are
72+
// then identical and no mapping is needed.
73+
func toLowerWithOffsets(s string) (string, []int) {
74+
if isASCII(s) {
75+
return strings.ToLower(s), nil
76+
}
77+
78+
lower := make([]byte, 0, len(s))
79+
offsets := make([]int, 0, len(s)+1)
80+
for i, r := range s {
81+
n := len(lower)
82+
lower = utf8.AppendRune(lower, unicode.ToLower(r))
83+
for range len(lower) - n {
84+
offsets = append(offsets, i)
85+
}
86+
}
87+
offsets = append(offsets, len(s))
88+
return string(lower), offsets
89+
}
90+
91+
// isASCII returns true if s consists of ASCII bytes only.
92+
func isASCII(s string) bool {
93+
for i := range len(s) {
94+
if s[i] >= utf8.RuneSelf {
95+
return false
96+
}
97+
}
98+
return true
5699
}
57100

58101
// searchWord String returns the search word.

oviewer/search_test.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,13 +848,55 @@ func Test_sensitive_FindAll(t *testing.T) {
848848
},
849849
want: [][]int{{0, 1}, {2, 3}, {4, 5}},
850850
},
851+
{
852+
// "İ" is 2 bytes but lowercases to the 1 byte "i",
853+
// so the match position must still refer to the original string.
854+
name: "testShrinkOnLower",
855+
fields: fields{
856+
searchWord: "error",
857+
searchReg: regexpCompile("error", false),
858+
caseSensitive: false,
859+
regexpSearch: false,
860+
},
861+
args: args{
862+
s: "İİ error here",
863+
},
864+
want: [][]int{{5, 10}},
865+
},
866+
{
867+
// "Ⱥ" is 2 bytes but lowercases to the 3 byte "ⱥ".
868+
name: "testGrowOnLower",
869+
fields: fields{
870+
searchWord: "error",
871+
searchReg: regexpCompile("error", false),
872+
caseSensitive: false,
873+
regexpSearch: false,
874+
},
875+
args: args{
876+
s: "Ⱥ error here",
877+
},
878+
want: [][]int{{3, 8}},
879+
},
851880
}
852881
for _, tt := range tests {
853882
t.Run(tt.name, func(t *testing.T) {
854883
substr := NewSearcher(tt.fields.searchWord, tt.fields.searchReg, tt.fields.caseSensitive, tt.fields.regexpSearch)
855-
if got := substr.FindAll(tt.args.s); !reflect.DeepEqual(got, tt.want) {
884+
got := substr.FindAll(tt.args.s)
885+
if !reflect.DeepEqual(got, tt.want) {
856886
t.Errorf("sensitiveWord.FindAll() = %v, want %v", got, tt.want)
857887
}
888+
for _, idx := range got {
889+
if tt.fields.regexpSearch {
890+
break
891+
}
892+
if idx[1] > len(tt.args.s) {
893+
t.Errorf("sensitiveWord.FindAll() index %v out of range of %q", idx, tt.args.s)
894+
continue
895+
}
896+
if matched := tt.args.s[idx[0]:idx[1]]; !strings.EqualFold(matched, tt.fields.searchWord) {
897+
t.Errorf("sensitiveWord.FindAll() matched %q, want %q", matched, tt.fields.searchWord)
898+
}
899+
}
858900
})
859901
}
860902
}

0 commit comments

Comments
 (0)