Allow escaping meta characters with backslashes

One can escape meta characters in extended-search mode with backslashes.

  Prefixes:
    \'
    \!
    \^

  Suffix:
    \$

  Term separator:
    \<SPACE>

To keep things simple, we are not going to support escaping of escaped
sequences (e.g. \\') for matching them literally.

Since this is a breaking change, we will bump the minor version.

Close #444
This commit is contained in:
Junegunn Choi 2017-08-09 23:25:32 +09:00
parent dc55e68524
commit e85a8a68d0
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
4 changed files with 63 additions and 22 deletions

View File

@ -53,13 +53,15 @@ type Pattern struct {
} }
var ( var (
_patternCache map[string]*Pattern _patternCache map[string]*Pattern
_splitRegex *regexp.Regexp _splitRegex *regexp.Regexp
_cache ChunkCache _escapedPrefixRegex *regexp.Regexp
_cache ChunkCache
) )
func init() { func init() {
_splitRegex = regexp.MustCompile("\\s+") _splitRegex = regexp.MustCompile(" +")
_escapedPrefixRegex = regexp.MustCompile("^\\\\['!^]")
clearPatternCache() clearPatternCache()
clearChunkCache() clearChunkCache()
} }
@ -80,7 +82,10 @@ func BuildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
var asString string var asString string
if extended { if extended {
asString = strings.Trim(string(runes), " ") asString = strings.TrimLeft(string(runes), " ")
for strings.HasSuffix(asString, " ") && !strings.HasSuffix(asString, "\\ ") {
asString = asString[:len(asString)-1]
}
} else { } else {
asString = string(runes) asString = string(runes)
} }
@ -140,12 +145,13 @@ func BuildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
} }
func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet { func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet {
str = strings.Replace(str, "\\ ", "\t", -1)
tokens := _splitRegex.Split(str, -1) tokens := _splitRegex.Split(str, -1)
sets := []termSet{} sets := []termSet{}
set := termSet{} set := termSet{}
switchSet := false switchSet := false
for _, token := range tokens { for _, token := range tokens {
typ, inv, text := termFuzzy, false, token typ, inv, text := termFuzzy, false, strings.Replace(token, "\t", " ", -1)
lowerText := strings.ToLower(text) lowerText := strings.ToLower(text)
caseSensitive := caseMode == CaseRespect || caseSensitive := caseMode == CaseRespect ||
caseMode == CaseSmart && text != lowerText caseMode == CaseSmart && text != lowerText
@ -167,6 +173,15 @@ func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet
text = text[1:] text = text[1:]
} }
if strings.HasSuffix(text, "$") {
if strings.HasSuffix(text, "\\$") {
text = text[:len(text)-2] + "$"
} else {
typ = termSuffix
text = text[:len(text)-1]
}
}
if strings.HasPrefix(text, "'") { if strings.HasPrefix(text, "'") {
// Flip exactness // Flip exactness
if fuzzy && !inv { if fuzzy && !inv {
@ -177,16 +192,16 @@ func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet
text = text[1:] text = text[1:]
} }
} else if strings.HasPrefix(text, "^") { } else if strings.HasPrefix(text, "^") {
if strings.HasSuffix(text, "$") { if typ == termSuffix {
typ = termEqual typ = termEqual
text = text[1 : len(text)-1]
} else { } else {
typ = termPrefix typ = termPrefix
text = text[1:]
} }
} else if strings.HasSuffix(text, "$") { text = text[1:]
typ = termSuffix }
text = text[:len(text)-1]
if _escapedPrefixRegex.MatchString(text) {
text = text[1:]
} }
if len(text) > 0 { if len(text) > 0 {
@ -236,7 +251,7 @@ func (p *Pattern) CacheKey() string {
cacheableTerms = append(cacheableTerms, string(termSet[0].text)) cacheableTerms = append(cacheableTerms, string(termSet[0].text))
} }
} }
return strings.Join(cacheableTerms, " ") return strings.Join(cacheableTerms, "\t")
} }
// Match returns the list of matches Items in the given Chunk // Match returns the list of matches Items in the given Chunk

View File

@ -165,15 +165,15 @@ func TestCacheKey(t *testing.T) {
t.Errorf("Expected: %s, actual: %s", expected, pat.CacheKey()) t.Errorf("Expected: %s, actual: %s", expected, pat.CacheKey())
} }
if pat.cacheable != cacheable { if pat.cacheable != cacheable {
t.Errorf("Expected: %s, actual: %s (%s)", cacheable, pat.cacheable, patStr) t.Errorf("Expected: %t, actual: %t (%s)", cacheable, pat.cacheable, patStr)
} }
clearPatternCache() clearPatternCache()
} }
test(false, "foo !bar", "foo !bar", true) test(false, "foo !bar", "foo !bar", true)
test(false, "foo | bar !baz", "foo | bar !baz", true) test(false, "foo | bar !baz", "foo | bar !baz", true)
test(true, "foo bar baz", "foo bar baz", true) test(true, "foo bar baz", "foo\tbar\tbaz", true)
test(true, "foo !bar", "foo", false) test(true, "foo !bar", "foo", false)
test(true, "foo !bar baz", "foo baz", false) test(true, "foo !bar baz", "foo\tbaz", false)
test(true, "foo | bar baz", "baz", false) test(true, "foo | bar baz", "baz", false)
test(true, "foo | bar | baz", "", false) test(true, "foo | bar | baz", "", false)
test(true, "foo | bar !baz", "", false) test(true, "foo | bar !baz", "", false)
@ -192,11 +192,11 @@ func TestCacheable(t *testing.T) {
} }
clearPatternCache() clearPatternCache()
} }
test(true, "foo bar", "foo bar", true) test(true, "foo bar", "foo\tbar", true)
test(true, "foo 'bar", "foo bar", false) test(true, "foo 'bar", "foo\tbar", false)
test(true, "foo !bar", "foo", false) test(true, "foo !bar", "foo", false)
test(false, "foo bar", "foo bar", true) test(false, "foo bar", "foo\tbar", true)
test(false, "foo 'bar", "foo", false) test(false, "foo 'bar", "foo", false)
test(false, "foo '", "foo", true) test(false, "foo '", "foo", true)
test(false, "foo 'bar", "foo", false) test(false, "foo 'bar", "foo", false)

View File

@ -281,9 +281,13 @@ func defaultKeymap() map[int][]action {
return keymap return keymap
} }
func trimQuery(query string) []rune {
return []rune(strings.Replace(query, "\t", " ", -1))
}
// NewTerminal returns new Terminal object // NewTerminal returns new Terminal object
func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
input := []rune(opts.Query) input := trimQuery(opts.Query)
var header []string var header []string
if opts.Reverse { if opts.Reverse {
header = opts.Header header = opts.Header
@ -1694,13 +1698,13 @@ func (t *Terminal) Loop() {
case actPreviousHistory: case actPreviousHistory:
if t.history != nil { if t.history != nil {
t.history.override(string(t.input)) t.history.override(string(t.input))
t.input = []rune(t.history.previous()) t.input = trimQuery(t.history.previous())
t.cx = len(t.input) t.cx = len(t.input)
} }
case actNextHistory: case actNextHistory:
if t.history != nil { if t.history != nil {
t.history.override(string(t.input)) t.history.override(string(t.input))
t.input = []rune(t.history.next()) t.input = trimQuery(t.history.next())
t.cx = len(t.input) t.cx = len(t.input)
} }
case actSigStop: case actSigStop:

View File

@ -1378,6 +1378,28 @@ class TestGoFZF < TestBase
tmux.send_keys 'a' tmux.send_keys 'a'
tmux.until { |lines| lines.none? { |line| line.include? '1 2 3 4 5' } } tmux.until { |lines| lines.none? { |line| line.include? '1 2 3 4 5' } }
end end
def test_escaped_meta_characters
input = <<~EOF
foo^bar
foo$bar
foo!bar
foo'bar
foo bar
bar foo
EOF
writelines tempname, input.lines.map(&:chomp)
assert_equal input.lines.count, `#{FZF} -f'foo bar' < #{tempname}`.lines.count
assert_equal ['foo bar'], `#{FZF} -f'foo\\ bar' < #{tempname}`.lines.map(&:chomp)
assert_equal ['bar foo'], `#{FZF} -f'foo$' < #{tempname}`.lines.map(&:chomp)
assert_equal ['foo$bar'], `#{FZF} -f'foo\\$' < #{tempname}`.lines.map(&:chomp)
assert_equal [], `#{FZF} -f'!bar' < #{tempname}`.lines.map(&:chomp)
assert_equal ['foo!bar'], `#{FZF} -f'\\!bar' < #{tempname}`.lines.map(&:chomp)
assert_equal ['foo bar'], `#{FZF} -f'^foo\\ bar$' < #{tempname}`.lines.map(&:chomp)
assert_equal [], `#{FZF} -f"'br" < #{tempname}`.lines.map(&:chomp)
assert_equal ["foo'bar"], `#{FZF} -f"\\'br" < #{tempname}`.lines.map(&:chomp)
end
end end
module TestShell module TestShell