From e85a8a68d0248a6edfb6ef63c5edb4bcbe18f954 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Wed, 9 Aug 2017 23:25:32 +0900 Subject: [PATCH] Allow escaping meta characters with backslashes One can escape meta characters in extended-search mode with backslashes. Prefixes: \' \! \^ Suffix: \$ Term separator: \ 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 --- src/pattern.go | 41 ++++++++++++++++++++++++++++------------- src/pattern_test.go | 12 ++++++------ src/terminal.go | 10 +++++++--- test/test_go.rb | 22 ++++++++++++++++++++++ 4 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/pattern.go b/src/pattern.go index 47cabf7..3432930 100644 --- a/src/pattern.go +++ b/src/pattern.go @@ -53,13 +53,15 @@ type Pattern struct { } var ( - _patternCache map[string]*Pattern - _splitRegex *regexp.Regexp - _cache ChunkCache + _patternCache map[string]*Pattern + _splitRegex *regexp.Regexp + _escapedPrefixRegex *regexp.Regexp + _cache ChunkCache ) func init() { - _splitRegex = regexp.MustCompile("\\s+") + _splitRegex = regexp.MustCompile(" +") + _escapedPrefixRegex = regexp.MustCompile("^\\\\['!^]") clearPatternCache() clearChunkCache() } @@ -80,7 +82,10 @@ func BuildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, var asString string 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 { 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 { + str = strings.Replace(str, "\\ ", "\t", -1) tokens := _splitRegex.Split(str, -1) sets := []termSet{} set := termSet{} switchSet := false for _, token := range tokens { - typ, inv, text := termFuzzy, false, token + typ, inv, text := termFuzzy, false, strings.Replace(token, "\t", " ", -1) lowerText := strings.ToLower(text) caseSensitive := caseMode == CaseRespect || caseMode == CaseSmart && text != lowerText @@ -167,6 +173,15 @@ func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet 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, "'") { // Flip exactness if fuzzy && !inv { @@ -177,16 +192,16 @@ func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet text = text[1:] } } else if strings.HasPrefix(text, "^") { - if strings.HasSuffix(text, "$") { + if typ == termSuffix { typ = termEqual - text = text[1 : len(text)-1] } else { typ = termPrefix - text = text[1:] } - } else if strings.HasSuffix(text, "$") { - typ = termSuffix - text = text[:len(text)-1] + text = text[1:] + } + + if _escapedPrefixRegex.MatchString(text) { + text = text[1:] } if len(text) > 0 { @@ -236,7 +251,7 @@ func (p *Pattern) CacheKey() string { 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 diff --git a/src/pattern_test.go b/src/pattern_test.go index 9d56ff9..efb1ef2 100644 --- a/src/pattern_test.go +++ b/src/pattern_test.go @@ -165,15 +165,15 @@ func TestCacheKey(t *testing.T) { t.Errorf("Expected: %s, actual: %s", expected, pat.CacheKey()) } 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() } test(false, "foo !bar", "foo !bar", 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 baz", "foo baz", false) + test(true, "foo !bar baz", "foo\tbaz", false) test(true, "foo | bar baz", "baz", false) test(true, "foo | bar | baz", "", false) test(true, "foo | bar !baz", "", false) @@ -192,11 +192,11 @@ func TestCacheable(t *testing.T) { } clearPatternCache() } - test(true, "foo bar", "foo bar", true) - test(true, "foo 'bar", "foo bar", false) + test(true, "foo bar", "foo\tbar", true) + test(true, "foo 'bar", "foo\tbar", 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 '", "foo", true) test(false, "foo 'bar", "foo", false) diff --git a/src/terminal.go b/src/terminal.go index 8c7c1a8..5c66f44 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -281,9 +281,13 @@ func defaultKeymap() map[int][]action { return keymap } +func trimQuery(query string) []rune { + return []rune(strings.Replace(query, "\t", " ", -1)) +} + // NewTerminal returns new Terminal object func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { - input := []rune(opts.Query) + input := trimQuery(opts.Query) var header []string if opts.Reverse { header = opts.Header @@ -1694,13 +1698,13 @@ func (t *Terminal) Loop() { case actPreviousHistory: if t.history != nil { t.history.override(string(t.input)) - t.input = []rune(t.history.previous()) + t.input = trimQuery(t.history.previous()) t.cx = len(t.input) } case actNextHistory: if t.history != nil { t.history.override(string(t.input)) - t.input = []rune(t.history.next()) + t.input = trimQuery(t.history.next()) t.cx = len(t.input) } case actSigStop: diff --git a/test/test_go.rb b/test/test_go.rb index f001ec6..3460f56 100644 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -1378,6 +1378,28 @@ class TestGoFZF < TestBase tmux.send_keys 'a' tmux.until { |lines| lines.none? { |line| line.include? '1 2 3 4 5' } } 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 module TestShell