branch: elpa/projectile
commit 83d4cdec70fe1dde13b3fadc6c6bbb66764c856f
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
Add whole-word matching to the reviewable search/replace
Whole-word is now a first-class search mode alongside literal/regexp and
case, following the exact same machinery:
- toggle it in the results buffer with `w' (sits next to `c'/`x'),
- default it with the new `projectile-search-whole-word' defcustom,
- seed it per invocation with the `projectile-dispatch' `--word' switch.
The elisp scan fences the pattern in shy-grouped word boundaries
(`\<\(?:...\)\>') and the ripgrep fast-path passes `--word-regexp', so
the two engines agree on what a whole-word match is. Word off is exactly
today's behavior.
---
CHANGELOG.md | 1 +
projectile.el | 132 ++++++++++++++++++++++++++--------
test/projectile-dispatch-test.el | 24 +++++--
test/projectile-search-review-test.el | 42 ++++++++++-
4 files changed, 162 insertions(+), 37 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4598f2d1a5..aaff9b9606 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,7 @@
* Add `projectile-frecency-max-projects` (default 100), which caps how many
projects' frecency history is kept so the store can't grow without bound.
* `projectile-replace-scan-chunk-size` is now a user-facing `defcustom` (was
an internal variable).
+* Add whole-word matching to the reviewable search/replace: toggle it with `w`
in the results buffer, default it with `projectile-search-whole-word`, or seed
it for one run with the `projectile-dispatch` `--word` switch. The elisp scan
fences the pattern with word boundaries and the ripgrep fast-path passes
`--word-regexp`, so both engines agree.
* Wire the reviewable search/replace into the `projectile-dispatch` Modifiers:
the two literal/regexp search entries are folded into one `sR` driven by
`--regexp` (matching how `--regexp` already drives the ag/ripgrep search), and
a new `--case-sensitive` switch seeds the reviewable search/replace
case-sensitive. Both can still be flipped with `x`/`c` in the results buffer.
* Highlight the search tool and the default value in the search prompts, and
give them one consistent `Search [tool] for (default: X)` format across
`projectile-search`, `projectile-grep`/`-ag`/`-ripgrep` and the reviewable
search. The tool that will run (`grep`/`ag`/`ripgrep`, or `ripgrep`/`elisp` for
the reviewable search's literal fast-path) is faced with
`projectile-search-prompt-tool` and the symbol-at-point default with
`projectile-search-prompt-default`. The reviewable search now [...]
diff --git a/projectile.el b/projectile.el
index 523727fe61..b42a779b8c 100644
--- a/projectile.el
+++ b/projectile.el
@@ -8035,6 +8035,16 @@ shown and a note is displayed."
:type 'natnum
:package-version '(projectile . "3.2.0"))
+(defcustom projectile-search-whole-word nil
+ "Whether the reviewable search/replace start in whole-word mode.
+Seeds `projectile-replace--word' for a fresh `projectile-search-review'
+or `projectile-replace-review'; you can still toggle it per search with
+`w' in the results buffer, and the `projectile-dispatch' `--word' switch
+binds it for a single invocation."
+ :group 'projectile
+ :type 'boolean
+ :package-version '(projectile . "3.2.0"))
+
(defcustom projectile-replace-async t
"Whether the reviewable search/replace commands scan asynchronously.
When non-nil (the default) `projectile-replace-review',
@@ -8163,6 +8173,11 @@ often; smaller values keep Emacs more responsive."
"Non-nil when the current search ignores case.
Initialized from `case-fold-search' at the first search and bound
around every (re-)gather so it drives which matches are collected.")
+(defvar-local projectile-replace--word nil
+ "Non-nil when the current search only matches whole words.
+Initialized from `projectile-search-whole-word' at the first search; the
+scan regexp is fenced with word boundaries (and ripgrep gets
+`--word-regexp') while it holds.")
(defvar-local projectile-replace--matches nil
"List of `projectile-replace--match' structs shown in the results buffer.")
(defvar-local projectile-replace--truncated nil
@@ -8536,6 +8551,7 @@ property, so match navigation skips it."
" "
(if projectile-replace--case-fold
"[ignore-case]" "[case-sensitive]")
+ (if projectile-replace--word " [word]" "")
(if projectile-replace--filtered " filtered" "")
(projectile-replace--scanning-note nmatches)))
(propertize
@@ -8580,6 +8596,7 @@ async scan streams matches in."
"\\[projectile-replace--quit] quit\n"
"\\[projectile-replace--toggle-case] case "
"\\[projectile-replace--toggle-regexp] regexp/literal "
+ "\\[projectile-replace--toggle-word] word "
"\\[projectile-replace--keep-matches]/\\[projectile-replace--flush-matches]
keep/flush line "
"\\[projectile-replace--keep-files]/\\[projectile-replace--flush-files]
keep/flush file\n\n")))
(if (null matches)
@@ -8772,6 +8789,17 @@ refused with a message so the buffer stays usable rather
than erroring."
(projectile-prepend-project-name
(if new-literal "literal search" "regexp search"))))))
+(defun projectile-replace--toggle-word ()
+ "Toggle whole-word matching of the search, re-scan, and re-render."
+ (interactive)
+ (setq projectile-replace--word (not projectile-replace--word))
+ (projectile-replace--regather)
+ (message "%s"
+ (projectile-prepend-project-name
+ (if projectile-replace--word
+ "search now matches whole words only"
+ "search now matches anywhere"))))
+
(defun projectile-replace--filter-by (predicate)
"Keep only matches satisfying PREDICATE, mark the list filtered, re-render.
The removed matches are recoverable by re-searching
(\\<projectile-replace-mode-map>\\[projectile-replace--refresh]), which
@@ -9033,6 +9061,7 @@ Projectile's own apply command
(define-key map (kbd "r") #'projectile-replace--set-replacement)
(define-key map (kbd "c") #'projectile-replace--toggle-case)
(define-key map (kbd "x") #'projectile-replace--toggle-regexp)
+ (define-key map (kbd "w") #'projectile-replace--toggle-word)
(define-key map (kbd "k") #'projectile-replace--keep-matches)
(define-key map (kbd "d") #'projectile-replace--flush-matches)
(define-key map (kbd "K") #'projectile-replace--keep-files)
@@ -9051,7 +9080,9 @@ Projectile's own apply command
Each match starts enabled and can be toggled on or off; only the
enabled matches are applied. Besides toggling and applying, the search
itself can be reshaped without leaving the buffer: toggle case
-sensitivity
(\\<projectile-replace-mode-map>\\[projectile-replace--toggle-case]) or
literal/regexp matching (\\[projectile-replace--toggle-regexp]) to re-scan, and
+sensitivity
(\\<projectile-replace-mode-map>\\[projectile-replace--toggle-case]),
+literal/regexp matching (\\[projectile-replace--toggle-regexp]) or whole-word
+matching (\\[projectile-replace--toggle-word]) to re-scan, and
narrow the shown matches by keeping or flushing them against a regexp
matched on the context line (\\[projectile-replace--keep-matches] /
\\[projectile-replace--flush-matches]) or the file name
(\\[projectile-replace--keep-files] / \\[projectile-replace--flush-files]).
Re-searching (\\[projectile-replace--refresh]) rebuilds the list from scratch,
@@ -9069,11 +9100,11 @@ write back to the files.
(buffer-disable-undo))
(defun projectile-replace--seed (buf mode root term regexp replacement
- literal case-fold)
+ literal case-fold &optional word)
"Put BUF in MODE and seed its results-buffer state, with no matches yet.
-ROOT, TERM, REGEXP, REPLACEMENT, LITERAL and CASE-FOLD seed the search
-parameters; the match list starts empty, ready for a (sync or async)
-scan to fill it."
+ROOT, TERM, REGEXP, REPLACEMENT, LITERAL, CASE-FOLD and WORD seed the
+search parameters; the match list starts empty, ready for a (sync or
+async) scan to fill it."
(with-current-buffer buf
(funcall mode)
(setq projectile-replace--root root
@@ -9082,6 +9113,7 @@ scan to fill it."
projectile-replace--replacement replacement
projectile-replace--literal literal
projectile-replace--case-fold case-fold
+ projectile-replace--word word
projectile-replace--matches nil
projectile-replace--truncated nil
projectile-replace--filtered nil
@@ -9119,18 +9151,20 @@ the deterministic elisp scan."
(projectile-search--rg-executable)
t))
-(defun projectile-search--rg-command (term case-fold globs)
+(defun projectile-search--rg-command (term case-fold word globs)
"Build the `rg --json' command line searching for literal TERM.
-CASE-FOLD selects case-insensitive matching; GLOBS is a list of ignore
-patterns (from `projectile--project-ignore-globs') passed as `--glob'
-exclusions so Projectile's ignores narrow ripgrep's own ignore rules.
-The search path is the relative `./', so the caller must run the process
-with `default-directory' bound to the project root."
+CASE-FOLD selects case-insensitive matching; WORD restricts matches to
+whole words (`--word-regexp'); GLOBS is a list of ignore patterns (from
+`projectile--project-ignore-globs') passed as `--glob' exclusions so
+Projectile's ignores narrow ripgrep's own ignore rules. The search path
+is the relative `./', so the caller must run the process with
+`default-directory' bound to the project root."
(append
(list (projectile-search--rg-executable)
"--json" "--fixed-strings" "--line-number" "--column"
"--color" "never"
(if case-fold "--ignore-case" "--case-sensitive"))
+ (when word (list "--word-regexp"))
;; Projectile's `project-ignores' globs mark a root-anchored pattern with a
;; leading `./' (e.g. a `.projectile' `-/vendor' line); ripgrep spells a
;; root-anchored glob with a leading `/', so translate `./PAT' to `/PAT'.
@@ -9253,8 +9287,9 @@ it, leaving no orphan. ON-DONE (or nil) is called in
BUFFER when the scan
finishes."
(let* ((root (buffer-local-value 'projectile-replace--root buffer))
(case-fold (buffer-local-value 'projectile-replace--case-fold buffer))
+ (word (buffer-local-value 'projectile-replace--word buffer))
(globs (projectile--project-ignore-globs root))
- (command (projectile-search--rg-command term case-fold globs))
+ (command (projectile-search--rg-command term case-fold word globs))
(pending ""))
(with-current-buffer buffer
(setq projectile-replace--matches nil
@@ -9305,6 +9340,21 @@ finishes."
(setq projectile-replace--scan-process proc))
proc)))
+(defun projectile-replace--word-boundary-regexp (regexp)
+ "Fence REGEXP with word boundaries so it only matches whole words.
+The pattern is shy-grouped and wrapped in `\\<'/`\\>', mirroring what
+ripgrep's `--word-regexp' does, so the elisp scan and the ripgrep
+fast-path agree on what counts as a whole-word match."
+ (concat "\\<\\(?:" regexp "\\)\\>"))
+
+(defun projectile-replace--effective-regexp (regexp)
+ "Return REGEXP as the current results buffer will actually match it.
+Fences it with word boundaries when `projectile-replace--word' is set,
+otherwise returns REGEXP unchanged."
+ (if projectile-replace--word
+ (projectile-replace--word-boundary-regexp regexp)
+ regexp))
+
(defun projectile-replace--start (buffer candidates regexp on-done)
"Fill BUFFER's match list by scanning CANDIDATES for REGEXP.
Cancels any in-flight scan in BUFFER first, then scans with the async
@@ -9326,11 +9376,15 @@ the regexp search always take the elisp path below."
(projectile-search--gather-rg
buffer (buffer-local-value 'projectile-replace--term buffer) on-done))
((projectile-replace--async-p)
- (projectile-replace--gather-async candidates regexp buffer on-done))
+ (projectile-replace--gather-async
+ candidates
+ (with-current-buffer buffer (projectile-replace--effective-regexp regexp))
+ buffer on-done))
(t
(with-current-buffer buffer
(let* ((case-fold-search projectile-replace--case-fold)
- (result (projectile-replace--gather candidates regexp)))
+ (result (projectile-replace--gather
+ candidates (projectile-replace--effective-regexp
regexp))))
(setq projectile-replace--matches (plist-get result :matches)
projectile-replace--truncated (plist-get result :truncated)
projectile-replace--scanning nil
@@ -9348,14 +9402,15 @@ the regexp search always take the elisp path below."
projectile-replace-max-matches))))))
(defun projectile-replace--open (mode buf-name root term regexp replacement
- literal case-fold candidates
no-match-msg)
+ literal case-fold candidates no-match-msg
+ &optional word)
"Open BUF-NAME in MODE and scan CANDIDATES for REGEXP into it.
When scanning is asynchronous the buffer is shown immediately and matches
stream in; when synchronous (always in batch) the scan completes first
and, to preserve the pre-async behavior, no buffer is shown when nothing
matched -- NO-MATCH-MSG is issued instead. Returns the results buffer,
or nil on the synchronous no-match path. ROOT, TERM, REGEXP,
-REPLACEMENT, LITERAL and CASE-FOLD seed the buffer state."
+REPLACEMENT, LITERAL, CASE-FOLD and WORD seed the buffer state."
;; re-running the command must not orphan a scan still filling an earlier
;; instance of the buffer (re-seeding resets its buffer-locals)
(when-let* ((existing (get-buffer buf-name)))
@@ -9369,7 +9424,7 @@ REPLACEMENT, LITERAL and CASE-FOLD seed the buffer state."
(projectile-search--rg-fastpath-p literal)))
(let ((buf (get-buffer-create buf-name)))
(projectile-replace--seed buf mode root term regexp replacement
- literal case-fold)
+ literal case-fold word)
(with-current-buffer buf
(setq projectile-replace--scanning t)
(funcall projectile-replace--render-function))
@@ -9378,14 +9433,17 @@ REPLACEMENT, LITERAL and CASE-FOLD seed the buffer
state."
#'projectile-replace--open-finish)
buf)
(let* ((case-fold-search case-fold)
- (result (projectile-replace--gather candidates regexp))
+ (scan-regexp (if word
+ (projectile-replace--word-boundary-regexp regexp)
+ regexp))
+ (result (projectile-replace--gather candidates scan-regexp))
(matches (plist-get result :matches))
(truncated (plist-get result :truncated)))
(if (null matches)
(progn (when no-match-msg (message "%s" no-match-msg)) nil)
(let ((buf (get-buffer-create buf-name)))
(projectile-replace--seed buf mode root term regexp replacement
- literal case-fold)
+ literal case-fold word)
(with-current-buffer buf
(setq projectile-replace--matches matches
projectile-replace--truncated truncated)
@@ -9418,11 +9476,13 @@ Emacs regexp and the replacement may reference capture
groups."
(format "Replace %s with: " term))))
(regexp (if literal (regexp-quote term) term))
(case-fold case-fold-search)
+ (word projectile-search-whole-word)
(candidates (projectile-replace--candidates term literal case-fold
root)))
(projectile-replace--open
#'projectile-replace-mode projectile-replace-buffer-name
root term regexp replacement literal case-fold candidates
- (projectile-prepend-project-name (format "No matches for %s" term)))))
+ (projectile-prepend-project-name (format "No matches for %s" term))
+ word)))
;;;###autoload
(defun projectile-replace-review ()
@@ -9482,6 +9542,7 @@ skips it."
" "
(if projectile-replace--case-fold
"[ignore-case]" "[case-sensitive]")
+ (if projectile-replace--word " [word]" "")
(if projectile-replace--filtered " filtered" "")
(projectile-replace--scanning-note nmatches)))
(propertize
@@ -9533,6 +9594,7 @@ property so the shared navigation, visit and filter
commands find it."
"\\[projectile-replace--quit] quit\n"
"\\[projectile-replace--toggle-case] case "
"\\[projectile-replace--toggle-regexp] regexp/literal "
+ "\\[projectile-replace--toggle-word] word "
"\\[projectile-replace--keep-matches]/\\[projectile-replace--flush-matches]
keep/flush line "
"\\[projectile-replace--keep-files]/\\[projectile-replace--flush-files]
keep/flush file\n\n")))
(if (null matches)
@@ -9561,6 +9623,7 @@ any filtering is undone and every match comes back
enabled), mirroring
(term projectile-replace--term)
(literal projectile-replace--literal)
(case-fold projectile-replace--case-fold)
+ (word projectile-replace--word)
(regexp projectile-replace--search)
(replacement (read-string
(projectile-prepend-project-name
@@ -9569,7 +9632,8 @@ any filtering is undone and every match comes back
enabled), mirroring
(projectile-replace--open
#'projectile-replace-mode projectile-replace-buffer-name
root term regexp replacement literal case-fold candidates
- (projectile-prepend-project-name (format "No matches for %s" term)))))
+ (projectile-prepend-project-name (format "No matches for %s" term))
+ word)))
(defvar projectile-search-mode-map
(let ((map (make-sparse-keymap)))
@@ -9580,6 +9644,7 @@ any filtering is undone and every match comes back
enabled), mirroring
(define-key map (kbd "M-p") #'projectile-replace--goto-prev-file)
(define-key map (kbd "c") #'projectile-replace--toggle-case)
(define-key map (kbd "x") #'projectile-replace--toggle-regexp)
+ (define-key map (kbd "w") #'projectile-replace--toggle-word)
(define-key map (kbd "k") #'projectile-replace--keep-matches)
(define-key map (kbd "d") #'projectile-replace--flush-matches)
(define-key map (kbd "K") #'projectile-replace--keep-files)
@@ -9599,7 +9664,9 @@ read-only listing of every match, grouped by file, with
no replacement
preview and no way to edit the files from here. Navigate with
\\<projectile-search-mode-map>\\[projectile-replace--goto-next-match] /
\\[projectile-replace--goto-prev-match] (match) and
\\[projectile-replace--goto-next-file] / \\[projectile-replace--goto-prev-file]
(file), and \\[projectile-replace--visit]
visits the match under point. The search can be reshaped in place:
-\\[projectile-replace--toggle-case] toggles case sensitivity and
\\[projectile-replace--toggle-regexp] toggles literal/regexp matching,
+\\[projectile-replace--toggle-case] toggles case sensitivity,
+\\[projectile-replace--toggle-regexp] toggles literal/regexp matching and
+\\[projectile-replace--toggle-word] toggles whole-word matching,
each re-scanning; \\[projectile-replace--keep-matches] /
\\[projectile-replace--flush-matches] keep or flush matches by line and
\\[projectile-replace--keep-files] / \\[projectile-replace--flush-files] by
file; \\[projectile-replace--refresh] re-runs the search, undoing any filtering.
@@ -9627,6 +9694,7 @@ Emacs regexp. There is no replacement prompt."
(if literal "" " regexp"))))
(regexp (if literal (regexp-quote term) term))
(case-fold case-fold-search)
+ (word projectile-search-whole-word)
(candidates (projectile-replace--candidates term literal case-fold
root)))
;; SEAM: everything above computes the candidate file list; the shared
;; opener below seeds the buffer and scans -- synchronously in batch,
@@ -9635,7 +9703,8 @@ Emacs regexp. There is no replacement prompt."
(projectile-replace--open
#'projectile-search-mode projectile-search-buffer-name
root term regexp nil literal case-fold candidates
- (projectile-prepend-project-name (format "No matches for %s" term)))))
+ (projectile-prepend-project-name (format "No matches for %s" term))
+ word)))
;;;###autoload
(defun projectile-search-review ()
@@ -11771,12 +11840,14 @@ PROPS is a plist of:
(defun projectile-dispatch-search-review ()
"Reviewable search honouring the `--regexp' and `--case-sensitive' switches.
`--regexp' runs the Emacs-regexp reviewer, `--case-sensitive' seeds the
-search case-sensitive; both can still be flipped (`x'/`c') in the results
+search case-sensitive; all can still be flipped (`x'/`c'/`w') in the results
buffer."
(interactive)
(let ((switches (projectile-dispatch--args)))
(let ((case-fold-search (if (member "--case-sensitive" switches)
- nil case-fold-search)))
+ nil case-fold-search))
+ (projectile-search-whole-word
+ (if (member "--word" switches) t projectile-search-whole-word)))
(call-interactively (if (member "--regexp" switches)
#'projectile-search-regexp-review
#'projectile-search-review)))))
@@ -11787,7 +11858,9 @@ Like `projectile-dispatch-search-review', but for the
replace reviewer."
(interactive)
(let ((switches (projectile-dispatch--args)))
(let ((case-fold-search (if (member "--case-sensitive" switches)
- nil case-fold-search)))
+ nil case-fold-search))
+ (projectile-search-whole-word
+ (if (member "--word" switches) t projectile-search-whole-word)))
(call-interactively (if (member "--regexp" switches)
#'projectile-replace-regexp-review
#'projectile-replace-review)))))
@@ -11826,13 +11899,15 @@ The switches in the Modifiers group tweak how the
commands below run:
`--invalidate-cache' rebuilds the file cache first (file/dir commands),
`--regexp' searches for a regexp (the ag/ripgrep search and the
reviewable search/replace), `--case-sensitive' seeds the reviewable
-search/replace case-sensitive, `--new-process' starts a fresh process
+search/replace case-sensitive, `--word' makes it match whole words,
+`--new-process' starts a fresh process
(shells), and `--display' opens the result in another window or frame
(file/buffer/project commands)."
["Modifiers"
("-i" "invalidate cache" "--invalidate-cache")
("-r" "regexp search" "--regexp")
("-c" "case-sensitive search" "--case-sensitive")
+ ("-w" "whole-word search" "--word")
("-n" "new process" "--new-process")
("-d" "display in" "--display="
:class transient-switches
@@ -11914,7 +11989,8 @@ The switches in the Modifiers group tweak how the
commands below run:
`--invalidate-cache' rebuilds the file cache first (file/dir commands),
`--regexp' searches for a regexp (the ag/ripgrep search and the
reviewable search/replace), `--case-sensitive' seeds the reviewable
-search/replace case-sensitive, `--new-process' starts a fresh process
+search/replace case-sensitive, `--word' makes it match whole words,
+`--new-process' starts a fresh process
(shells), and `--display' opens the result in another window or frame
(file/buffer/project commands)."
(interactive)
diff --git a/test/projectile-dispatch-test.el b/test/projectile-dispatch-test.el
index 846e02f4fa..79d5f8a8ea 100644
--- a/test/projectile-dispatch-test.el
+++ b/test/projectile-dispatch-test.el
@@ -79,12 +79,15 @@
;; seed case sensitivity from `--case-sensitive' (by binding
;; `case-fold-search' around the call). Capture both the command run and
;; the `case-fold-search' in effect at that moment.
- :var (captured cfs)
+ :var (captured cfs pww)
(before-each
- (setq captured nil cfs 'unset)
+ (setq captured nil cfs 'unset pww 'unset)
(spy-on 'projectile-dispatch--args :and-return-value nil)
(spy-on 'call-interactively :and-call-fake
- (lambda (command) (setq captured command cfs case-fold-search))))
+ (lambda (command)
+ (setq captured command
+ cfs case-fold-search
+ pww projectile-search-whole-word))))
(it "runs the literal search reviewer and leaves case as-is with no
modifiers"
(let ((case-fold-search t))
@@ -111,12 +114,21 @@
(expect captured :to-be 'projectile-search-regexp-review)
(expect cfs :to-be nil))
+ (it "seeds whole-word matching when --word is active"
+ (spy-on 'projectile-dispatch--args :and-return-value '("--word"))
+ (let ((projectile-search-whole-word nil))
+ (projectile-dispatch-search-review))
+ (expect captured :to-be 'projectile-search-review)
+ (expect pww :to-be t))
+
(it "applies the same switches to the replace reviewer"
- (spy-on 'projectile-dispatch--args :and-return-value '("--regexp"
"--case-sensitive"))
- (let ((case-fold-search t))
+ (spy-on 'projectile-dispatch--args
+ :and-return-value '("--regexp" "--case-sensitive" "--word"))
+ (let ((case-fold-search t) (projectile-search-whole-word nil))
(projectile-dispatch-replace-review))
(expect captured :to-be 'projectile-replace-regexp-review)
- (expect cfs :to-be nil)))
+ (expect cfs :to-be nil)
+ (expect pww :to-be t)))
(describe "projectile-dispatch prefix"
(it "is defined as a transient prefix"
diff --git a/test/projectile-search-review-test.el
b/test/projectile-search-review-test.el
index aae37d1445..8bf84299ca 100644
--- a/test/projectile-search-review-test.el
+++ b/test/projectile-search-review-test.el
@@ -111,6 +111,36 @@ REGEXP-P selects `projectile-search-regexp-review'."
(projectile-search--review nil)
(expect label :to-match (regexp-quote "[elisp]")))))
+(describe "projectile-search-review whole-word"
+ (it "fences the pattern in shy-grouped word boundaries"
+ (expect (projectile-replace--word-boundary-regexp "foo\\|bar")
+ :to-equal "\\<\\(?:foo\\|bar\\)\\>"))
+
+ (it "matches only whole-word occurrences when whole-word mode is on"
+ (projectile-test-with-project
+ (("a.txt" . "foo foobar barfoo foo\n"))
+ (projectile-test-use-plain-grep)
+ (let* ((projectile-search-whole-word t)
+ (buf (projectile-search-review-test--run "foo")))
+ (with-current-buffer buf
+ (expect projectile-replace--word :to-be t)
+ ;; the two standalone `foo's match, foobar/barfoo do not
+ (expect (length projectile-replace--matches) :to-equal 2)
+ (expect (buffer-string) :to-match "\\[word\\]")))))
+
+ (it "toggling whole-word off re-scans and matches substrings again"
+ (projectile-test-with-project
+ (("a.txt" . "foo foobar barfoo foo\n"))
+ (projectile-test-use-plain-grep)
+ (let* ((projectile-search-whole-word t)
+ (buf (projectile-search-review-test--run "foo")))
+ (with-current-buffer buf
+ (expect (length projectile-replace--matches) :to-equal 2)
+ ;; batch scans synchronously, so the re-gather is done on return
+ (projectile-replace--toggle-word)
+ (expect projectile-replace--word :to-be nil)
+ (expect (length projectile-replace--matches) :to-equal 4))))))
+
(describe "projectile-search-regexp-review"
(it "honors Emacs-only regexp constructs a shell regexp couldn't express"
(projectile-test-with-project
@@ -374,10 +404,12 @@ REGEXP-P selects `projectile-search-regexp-review'."
(describe "projectile-search--rg-command"
(it "builds a fixed-strings, ignore-aware command with the term after --"
(let ((cmd (projectile-search--rg-command
- "foo-bar" t '("node_modules/" "*.elc" "./vendor/"))))
+ "foo-bar" t nil '("node_modules/" "*.elc" "./vendor/"))))
(expect (member "--fixed-strings" cmd) :to-be-truthy)
(expect (member "--ignore-case" cmd) :to-be-truthy)
(expect (member "--case-sensitive" cmd) :to-be nil)
+ ;; no whole-word restriction unless requested
+ (expect (member "--word-regexp" cmd) :to-be nil)
;; a basename glob becomes a negated --glob verbatim
(expect (member "!node_modules/" cmd) :to-be-truthy)
(expect (member "!*.elc" cmd) :to-be-truthy)
@@ -390,9 +422,13 @@ REGEXP-P selects `projectile-search-regexp-review'."
(expect (cadr tail) :to-equal "./"))))
(it "uses --case-sensitive when case-fold is nil"
- (let ((cmd (projectile-search--rg-command "foo" nil nil)))
+ (let ((cmd (projectile-search--rg-command "foo" nil nil nil)))
(expect (member "--case-sensitive" cmd) :to-be-truthy)
- (expect (member "--ignore-case" cmd) :to-be nil))))
+ (expect (member "--ignore-case" cmd) :to-be nil)))
+
+ (it "adds --word-regexp when whole-word matching is requested"
+ (let ((cmd (projectile-search--rg-command "foo" nil t nil)))
+ (expect (member "--word-regexp" cmd) :to-be-truthy))))
(describe "projectile-search--rg-ingest streaming and cap"
(it "honors projectile-replace-max-matches and marks the list truncated"