Repository: junegunn/fzf (language: go)

Issue:
Matching with word boundaries
### Checklist

- [X] I have read through the manual page (`man fzf`)
- [X] I have searched through the existing issues
- [ ] For bug reports, I have checked if the bug is reproducible in the latest version of fzf

### Output of `fzf --version`

0.54.3 (c423c496)

### OS

- [X] Linux
- [ ] macOS
- [ ] Windows
- [ ] Etc.

### Shell

- [X] bash
- [ ] zsh
- [ ] fish

### Problem / Steps to reproduce

Hello,

recently I started using fzf (mainly with vim which is my primary development editor) and it is really a game-changer for me. Unfortunately, there is a major weakness which I face quite often in my daily work: inability to make fzf query more strict by specifying that its part(s) should be matched against whole word(s), i.e. with word boundary check.

The sequence of actions is usually as follows:
- in vim: feeding fzf in fuzzy mode with results from 'ag' to find where a term is used in source code
- finding hundreds or even thousands of lines
- trying to reduce the number of results by prefixing the pattern with '
- there are still too many results (e.g. if I look for a variable "Window" there may be many results like "bottom_window" or "InitWindow()")
- switching to other methods like running 'ag -w' in shell

It would be very helpful to have a way to toggle word boundaries check for a part of query, for example by:
- prefixing with "
- surrounding with '
- introducing regular expressions of some kind

Also it would be very nice to keep fuzzy mode still possible for other parts of the same query.

I know there are a number of similar issues requesting features like toggling "case + non-fuzzy" mode or something. It seems the closest one is #2394. Sadly, it looks like such requests are often rejected, so here are some argument in favor.

In my opinion, now fzf is not just a fuzzy finder, its primary goal is to allow a **quick** selection of one or few items among many others. It provides different modes and ways for doing that. It is a selector as such, it can switch quickly between fuzzy and non-fuzzy mode, it can stick the search pattern to beginning or end of line and so on. Thus, it is very disappointing if none of those modes and switches help and fzf still fails to limit the number of items in the list enough.

Switching to a different source of data (e.g. running ag with '-w') is too long and inconvenient and it may require several iterations of refining the query pipeline.

As a programmer I have had a quick look at the sources of fzf (though, I haven't used Golang before) and it seems the implementation should not be difficult. Please, consider it.


Discussion / hints:
> * switching to other methods like running 'ag -w' in shell

> Switching to a different source of data (e.g. running ag with '-w') is too long and inconvenient and it may require several iterations of refining the query pipeline.

I think you're looking for `change:reload` approach explained in https://junegunn.github.io/fzf/tips/ripgrep-integration/

[fzf.vim](https://github.com/junegunn/fzf.vim) provides this functionality as `:RG` (all caps) command.
Thank you for the quick response!

I have already been using `reload` with a binding for viewing git commits via fzf (for switching between all commits and current branch commits) but I missed the idea that the whole query can be passed to external command and that the reload can be initiated on every change of the query string. This way it is even possible to use regexp in the query. Cool! Though, isn't it to expensive to re-run rg/ag on a large set of files after every single keypress (when it changes the query string)?

Anyway, it still looks to me more natural and convenient (considering the existing switches of the extended search mode) and also quicker (no reload of data) and more unified (taking into account different commands in vim where fzf is used) to add just another switch for enabling word boundary for parts of the search query. I have already done that (a draft version) for myself in a local repository of fzf and so far it looks to be working fine. Probably I will stick to this approach.

> isn't it to expensive

It depends. It can be more expensive to load everything in memory and run fuzzy matching algorithm which is likely less efficient than the search algorithm of ripgrep in this particular scenario. Anyway, it works really well, and the performance has never been an issue for me. You should try it out yourself.
I have just had a first try of it. Yes, it looks to be working well (though, I executed 'ag' instead of 'rg' with fzf#vim#grep2()). And I agree that the performance depends on scenario: the reload seems to be better with separate independent queries while loading everything at start and then just filtering with fzf may be preferable when you do a research on the same source data (code base) and when on each iteration you inspect results in the preview window and change the query to find something else without returning to vim/shell (this is sometimes the case for me).

However, the approach with reload proposed by you does not allow combining part of queries bound to words with parts that should be matched with fuzzy algorithm. And also I doubt that it is usable in cases when the source of data is not a grep-like command. Anyway, thank you for the very good tool and your help!

Just in case if someone (as me) still wants feature "Matching with word boundaries", here is my draft version in a fork repository: https://github.com/maxaykin/fzf/commit/38d37d702a68800aa063ffe180f7c76a73421232
FWIW,

1. There is an example in ADVANCED.md where you can dynamically switch between fzf mode and ripgrep mode.
    * https://github.com/junegunn/fzf/blob/master/ADVANCED.md#switching-between-ripgrep-mode-and-fzf-mode-using-a-single-key-binding
1. You can match literal space by escaping it with a backslash, so I think `'\ foo\ ` can help in some cases.

Fix patch:
diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml
index 63a80e84425..e4117238490 100644
--- a/.github/workflows/linux.yml
+++ b/.github/workflows/linux.yml
@@ -36,7 +36,7 @@ jobs:
       run: sudo apt-get install --yes zsh fish tmux
 
     - name: Install Ruby gems
-      run: sudo gem install --no-document minitest:5.17.0 rubocop:1.43.0 rubocop-minitest:0.25.1 rubocop-performance:1.15.2
+      run: sudo gem install --no-document minitest:5.25.1 rubocop:1.65.0 rubocop-minitest:0.35.1 rubocop-performance:1.21.1
 
     - name: Rubocop
       run: rubocop --require rubocop-minitest --require rubocop-performance
diff --git a/.rubocop.yml b/.rubocop.yml
index 80baa49185a..28d8a340e49 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -6,7 +6,7 @@ Lint/ShadowingOuterLocalVariable:
   Enabled: false
 Style/MethodCallWithArgsParentheses:
   Enabled: true
-  IgnoredMethods:
+  AllowedMethods:
     - assert
     - exit
     - paste
@@ -15,7 +15,7 @@ Style/MethodCallWithArgsParentheses:
     - refute
     - require
     - send_keys
-  IgnoredPatterns:
+  AllowedPatterns:
     - ^assert_
     - ^refute_
 Style/NumericPredicate:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24a13b4d4d1..fdf9c1ddf92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,13 @@ CHANGELOG
 
 0.55.0
 ------
+- Added `exact-boundary-match` type to the search syntax. When a search term is single-quoted, fzf will search for the exact occurrences of the string with both ends at word boundaries.
+  ```sh
+  fzf --query "'here'" << EOF
+  come here
+  not there
+  EOF
+  ```
 - [bash] Fuzzy path completion is enabled for all commands
     - 1. If the default completion is not already set
     - 2. And if the current bash supports `complete -D` option
diff --git a/Makefile b/Makefile
index 56bb1c1e0a2..7c6bd54b461 100644
--- a/Makefile
+++ b/Makefile
@@ -77,7 +77,6 @@ endif
 all: target/$(BINARY)
 
 test: $(SOURCES)
-	[ -z "$$(gofmt -s -d src)" ] || (gofmt -s -d src; exit 1)
 	SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" \
 				github.com/junegunn/fzf/src \
 				github.com/junegunn/fzf/src/algo \
@@ -87,6 +86,10 @@ test: $(SOURCES)
 bench:
 	cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
 
+lint: $(SOURCES) test/test_go.rb
+	[ -z "$$(gofmt -s -d src)" ] || (gofmt -s -d src; exit 1)
+	rubocop --require rubocop-minitest --require rubocop-performance
+
 install: bin/fzf
 
 generate:
@@ -184,4 +187,4 @@ update:
 	$(GO) get -u
 	$(GO) mod tidy
 
-.PHONY: all generate build release test bench install clean docker docker-test update
+.PHONY: all generate build release test bench lint install clean docker docker-test update
diff --git a/README.md b/README.md
index 1ee105814ed..950cf40a1e1 100644
--- a/README.md
+++ b/README.md
@@ -376,15 +376,16 @@ Unless otherwise specified, fzf starts in "extended-search mode" where you can
 type in multiple search terms delimited by spaces. e.g. `^music .mp3$ sbtrkt
 !fire`
 
-| Token     | Match type                 | Description                          |
-| --------- | -------------------------- | ------------------------------------ |
-| `sbtrkt`  | fuzzy-match                | Items that match `sbtrkt`            |
-| `'wild`   | exact-match (quoted)       | Items that include `wild`            |
-| `^music`  | prefix-exact-match         | Items that start with `music`        |
-| `.mp3$`   | suffix-exact-match         | Items that end with `.mp3`           |
-| `!fire`   | inverse-exact-match        | Items that do not include `fire`     |
-| `!^music` | inverse-prefix-exact-match | Items that do not start with `music` |
-| `!.mp3$`  | inverse-suffix-exact-match | Items that do not end with `.mp3`    |
+| Token     | Match type                              | Description                                  |
+| --------- | --------------------------------------  | ------------------------------------------   |
+| `sbtrkt`  | fuzzy-match                             | Items that match `sbtrkt`                    |
+| `'wild`   | exact-match (quoted)                    | Items that include `wild`                    |
+| `'wild'`  | exact-boundary-match (quoted both ends) | Items that include `wild` at word boundaries |
+| `^music`  | prefix-exact-match                      | Items that start with `music`                |
+| `.mp3$`   | suffix-exact-match                      | Items that end with `.mp3`                   |
+| `!fire`   | inverse-exact-match                     | Items that do not include `fire`             |
+| `!^music` | inverse-prefix-exact-match              | Items that do not start with `music`         |
+| `!.mp3$`  | inverse-suffix-exact-match              | Items that do not end with `.mp3`            |
 
 If you don't prefer fuzzy matching and do not wish to "quote" every word,
 start fzf with `-e` or `--exact` option. Note that when  `--exact` is set,
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index 8ab7d7de2b4..ed1314cb1fc 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -1146,6 +1146,22 @@ A term can be prefixed by \fB^\fR, or suffixed by \fB$\fR to become an
 anchored-match term. Then fzf will search for the lines that start with or end
 with the given string. An anchored-match term is also an exact-match term.
 
+.SS Exact\-boundary\-match (quoted both ends)
+A single-quoted term is interpreted as an "exact\-boundary\-match". fzf will
+search for the exact occurrences of the string with both ends at the word
+boundaries. Unlike in regular expressions, this also sees an underscore as
+a word boundary. But the words around underscores are ranked lower and appear
+later in the result than the other words around the other types of word
+boundaries.
+
+1. xxx foo xxx (highest score)
+.br
+2. xxx foo_xxx
+.br
+3. xxx_foo xxx
+.br
+4. xxx_foo_xxx (lowest score)
+
 .SS Negation
 If a term is prefixed by \fB!\fR, fzf will exclude the lines that satisfy the
 term from the result. In this case, fzf performs exact match by default.
diff --git a/src/algo/algo.go b/src/algo/algo.go
index c85ec82e317..f0569185c11 100644
--- a/src/algo/algo.go
+++ b/src/algo/algo.go
@@ -798,6 +798,14 @@ func FuzzyMatchV1(caseSensitive bool, normalize bool, forward bool, text *util.C
 // The solution is much cheaper since there is only one possible alignment of
 // the pattern.
 func ExactMatchNaive(caseSensitive bool, normalize bool, forward bool, text *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
+	return exactMatchNaive(caseSensitive, normalize, forward, false, text, pattern, withPos, slab)
+}
+
+func ExactMatchBoundary(caseSensitive bool, normalize bool, forward bool, text *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
+	return exactMatchNaive(caseSensitive, normalize, forward, true, text, pattern, withPos, slab)
+}
+
+func exactMatchNaive(caseSensitive bool, normalize bool, forward bool, boundaryCheck bool, text *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
 	if len(pattern) == 0 {
 		return Result{0, 0, 0}, nil
 	}
@@ -832,10 +840,22 @@ func ExactMatchNaive(caseSensitive bool, normalize bool, forward bool, text *uti
 		}
 		pidx_ := indexAt(pidx, lenPattern, forward)
 		pchar := pattern[pidx_]
-		if pchar == char {
+		ok := pchar == char
+		if ok {
 			if pidx_ == 0 {
 				bonus = bonusAt(text, index_)
 			}
+			if boundaryCheck {
+				ok = bonus >= bonusBoundary
+				if ok && pidx_ == 0 {
+					ok = index_ == 0 || charClassOf(text.Get(index_-1)) <= charDelimiter
+				}
+				if ok && pidx_ == len(pattern)-1 {
+					ok = index_ == lenRunes-1 || charClassOf(text.Get(index_+1)) <= charDelimiter
+				}
+			}
+		}
+		if ok {
 			pidx++
 			if pidx == lenPattern {
 				if bonus > bestBonus {
@@ -861,7 +881,23 @@ func ExactMatchNaive(caseSensitive bool, normalize bool, forward bool, text *uti
 			sidx = lenRunes - (bestPos + 1)
 			eidx = lenRunes - (bestPos - lenPattern + 1)
 		}
-		score, _ := calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false)
+		var score int
+		if boundaryCheck {
+			// Underscore boundaries should be ranked lower than the other types of boundaries
+			score = int(bonus)
+			deduct := int(bonus-bonusBoundary) + 1
+			if sidx > 0 && text.Get(sidx-1) == '_' {
+				score -= deduct + 1
+				deduct = 1
+			}
+			if eidx < lenRunes && text.Get(eidx) == '_' {
+				score -= deduct
+			}
+			// Add base score so that this can compete with other match types e.g. 'foo' | bar
+			score += scoreMatch*lenPattern + int(bonusBoundaryWhite)*(lenPattern+1)
+		} else {
+			score, _ = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false)
+		}
 		return Result{sidx, eidx, score}, nil
 	}
 	return Result{-1, -1, 0}, nil
diff --git a/src/pattern.go b/src/pattern.go
index ee1b88a529e..11bed5f9a6f 100644
--- a/src/pattern.go
+++ b/src/pattern.go
@@ -23,6 +23,7 @@ type termType int
 const (
 	termFuzzy termType = iota
 	termExact
+	termExactBoundary
 	termPrefix
 	termSuffix
 	termEqual
@@ -147,6 +148,7 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
 	ptr.procFun[termFuzzy] = fuzzyAlgo
 	ptr.procFun[termEqual] = algo.EqualMatch
 	ptr.procFun[termExact] = algo.ExactMatchNaive
+	ptr.procFun[termExactBoundary] = algo.ExactMatchBoundary
 	ptr.procFun[termPrefix] = algo.PrefixMatch
 	ptr.procFun[termSuffix] = algo.SuffixMatch
 
@@ -193,7 +195,10 @@ func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet
 			text = text[:len(text)-1]
 		}
 
-		if strings.HasPrefix(text, "'") {
+		if len(text) > 2 && strings.HasPrefix(text, "'") && strings.HasSuffix(text, "'") {
+			typ = termExactBoundary
+			text = text[1 : len(text)-1]
+		} else if strings.HasPrefix(text, "'") {
 			// Flip exactness
 			if fuzzy && !inv {
 				typ = termExact


Test patch:
diff --git a/test/test_go.rb b/test/test_go.rb
index 3b5eb4ee367..bb3493fb7c5 100755
--- a/test/test_go.rb
+++ b/test/test_go.rb
@@ -3366,6 +3366,18 @@ def test_start_on_reload
     tmux.send_keys :Space
     tmux.until { |lines| assert_includes lines[-3], 'bar' }
   end
+
+  def test_boundary_match
+    # Underscore boundaries should be ranked lower
+    {
+      default: [' x '] + %w[/x/ [x] -x- -x_ _x- _x_],
+      path: ['/x/', ' x '] + %w[[x] -x- -x_ _x- _x_],
+      history: ['[x]', '-x-', ' x '] + %w[/x/ -x_ _x- _x_]
+    }.each do |scheme, expected|
+      result = `printf -- 'xxx\n-xx\nxx-\n_x_\n_x-\n-x_\n[x]\n-x-\n x \n/x/\n' | #{FZF} -f"'x'" --scheme=#{scheme}`.lines(chomp: true)
+      assert_equal expected, result
+    end
+  end
 end
 
 module TestShell