[PATCH] Optional preview of the replacement text while typing it
Rahul Martim Juliato <[email protected]> Thu, 06 Aug 2026 01:56:52 -0300
| Newsgroups | gmane.emacs.devel |
|---|---|
| Message-ID | <[email protected]> |
Hello,
I've been working on this idea for the past few weeks and have prepared
a more polished patch to propose for discussion.
I love making substitutions in Emacs, with `replace-string',
`replace-regexp', `query-replace' and from isearch with `C-s ... M-%'.
The live preview as you type is fantastic, especially when trying out a
complex regexp and spotting matches I didn't intend. It's a great
quality-of-life feature.
Unfortunately, the replacement side has no such feedback. While I am
typing the replacement string there is no indication of what the
replacement will look like. For `query-replace', I only see it after
finishing the replacement string for `replace-string' and
`replace-regexp', the first feedback is the buffer already modified, and
the only way back is undo.
So I'm proposing an optional preview feature: while the replacement
string is being read, the matches visible in the window are displayed as
they would look after the replacement, using a new face
`query-replace-preview'. The text goes back to normal when you leave
the minibuffer. All the replacement commands get this, since they read
the replacement through `query-replace-read-to'.
The attached screenshots show it in action with some examples. In
search-replace-01.png I am typing the pattern `\(emacs\)' over etc/NEWS
and Emacs highlights the matches, which is what it already does today.
In search-replace-02.png I am typing the replacement `--\1--' and each
match shows what it will become.
The other three screenshots illustrate a shy group case (one I always
miss). In search-replace-03.png the pattern
`\(?:release\|debug\)-\([0-9]\)\.\([0-9]\)' matches four lines. In
search-replace-04.png I typed `v\2.\3', which is the guess I make when I
count the shy group as a group: the preview shows `v2.', `v4.' and the
missing digit is there to see before I press RET. search-replace-05.png
is the same buffer after fixing the replacement to `v\1.\2'. Without
needing to undo, we spotted the problem beforehand thanks to the
proposed feature.
To me this is a quality of life improvement that brings the replacement
side closer to the finding side. Since Emacs already shows me what I am
about to find while I type it, and I would like it to show me what I am
about to write while I type that too.
The patch is attached. Some notes:
- The new option `query-replace-show-preview' defaults to nil. Since
this is a visual change to the buffer, I think opting in is the
safer call, though it is debatable, and I am happy to flip it.
- The preview follows the same shape as the lazy highlight of the
search string (`minibuffer-lazy-highlight-setup' in isearch.el), and
only covers the matches visible in the selected window, so the work
per keystroke is bounded by a screenful.
- A replacement that is shorter or longer than the match shifts the
surrounding text while you type, as you can see happening to the
second column between screenshots 03 and 04. The overlays use a
`display' property, so what you see is the width the buffer will
really have.
- The search side reuses `replace-search' and the region is honoured
through `replace--region-filter', so what you see previewed is what
`perform-replace' will match.
- \, and \# are not previewed. Evaluating \, on each keystroke would
run the side effects of Emacs Lisp code the user is still typing,
and \# expands to the number of replacements made so far, which is
still 0 for every match.
I'd appreciate feedback on both the idea and the implementation. If
there is interest in the feature, I'm happy to keep refining the patch.
Thanks,
--
Rahul Martim Juliato
search-replace-01.png
(image/png, 165.1 KB) - not displayed
search-replace-02.png
(image/png, 166.2 KB) - not displayed
search-replace-03.png
(image/png, 106.8 KB) - not displayed
search-replace-04.png
(image/png, 104.7 KB) - not displayed
search-replace-05.png
(image/png, 105.3 KB) - not displayed
0001-Preview-the-replacement-text-while-it-is-typed.patch
(application/octet-stream, 14.7 KB)
From 6840d686bccc3435da8830ee4c94dab2e895779c Mon Sep 17 00:00:00 2001 From: Rahul Martim Juliato <[email protected]> Date: Thu, 6 Aug 2026 01:00:34 -0300 Subject: [PATCH] Preview the replacement text while it is typed * lisp/replace.el (query-replace-show-preview): New user option. (query-replace-preview): New face. (query-replace-eval-replacement-regexp): New constant, extracted from... (query-replace-compile-replacement): ...here. (replace-preview-overlays): New variable. (replace-preview-cleanup, replace-preview-update) (replace-preview-setup): New functions. (query-replace-read-to): New optional argument DELIMITED-FLAG. (query-replace-read-args): Pass the delimited flag to 'query-replace-read-to'. * lisp/isearch.el (isearch-query-replace): Pass the delimited flag to 'query-replace-read-to'. * test/lisp/replace-tests.el (replace-tests--preview): New helper. (replace-tests-preview-update, replace-tests-preview-cleanup) (replace-tests-preview-disabled): New tests. * doc/emacs/search.texi (Replace): Document the preview. * etc/NEWS: Announce it. --- doc/emacs/search.texi | 11 +++ etc/NEWS | 8 ++ lisp/isearch.el | 2 +- lisp/replace.el | 152 ++++++++++++++++++++++++++++++++++--- test/lisp/replace-tests.el | 49 ++++++++++++ 5 files changed, 212 insertions(+), 10 deletions(-) diff --git a/doc/emacs/search.texi b/doc/emacs/search.texi index 314f1de8e83..38ce36ee017 100644 --- a/doc/emacs/search.texi +++ b/doc/emacs/search.texi @@ -1564,6 +1564,17 @@ Replace is possible to perform several replacements in parallel, using the command @code{expand-region-abbrevs} (@pxref{Expanding Abbrevs}). +@cindex preview of replacement text +@cindex @code{query-replace-preview} face +@vindex query-replace-show-preview + If you set @code{query-replace-show-preview} to a non-@code{nil} +value, the replace commands preview the replacement while you type it: +the matches visible in the window are displayed as they would look +after the replacement, using the face @code{query-replace-preview}. +This tells you what back-references like @samp{\1} (@pxref{Regexp +Replace}) expand to before you commit to the edit. Replacements that +use @samp{\,} or @samp{\#} are not previewed. + @menu * Unconditional Replace:: Replacing all matches for a string. * Regexp Replace:: Replacing all matches for a regexp. diff --git a/etc/NEWS b/etc/NEWS index 3aba7e9db15..a10d9be3d2a 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -110,6 +110,14 @@ to your initialization file no longer apply, and you may remove it. * Editing Changes in Emacs 32.1 ++++ +** New user option 'query-replace-show-preview'. +When set to t, the replacement commands preview the replacement while +you type it: the matches visible in the window are shown as they would +look after the replacement, using the new face 'query-replace-preview'. +This tells you what back-references like '\1' expand to before you +commit to the edit. The preview is off by default. + * Changes in Specialized Modes and Packages in Emacs 32.1 diff --git a/lisp/isearch.el b/lisp/isearch.el index 3d594c72780..0b4acfc589f 100644 --- a/lisp/isearch.el +++ b/lisp/isearch.el @@ -2450,7 +2450,7 @@ isearch-query-replace (isearch--describe-regexp-mode (or delimited isearch-regexp-function) t) (if backward " backward" "") (if (use-region-p) " in region" "")) - isearch-regexp) + isearch-regexp (or delimited isearch-regexp-function)) t isearch-regexp (or delimited isearch-regexp-function) nil nil (use-region-beginning) (use-region-end) backward)) diff --git a/lisp/replace.el b/lisp/replace.el index be8b4d1baff..0101978439f 100644 --- a/lisp/replace.el +++ b/lisp/replace.el @@ -114,6 +114,16 @@ query-replace-show-replacement :group 'matching :version "23.1") +(defcustom query-replace-show-preview nil + "Non-nil means preview the replacement while you type it. +The matches visible in the window are shown as they would look after +the replacement, using the `query-replace-preview' face. This tells +you what back-references like \\1 expand to before you commit to the +edit. Replacements that use \\, or \\# are never previewed." + :type 'boolean + :group 'matching + :version "32.1") + (defcustom query-replace-highlight t "Non-nil means to highlight matches during query replacement." :type 'boolean @@ -151,6 +161,14 @@ query-replace :group 'matching :version "22.1") +(defface query-replace-preview + '((t (:inherit query-replace))) + "Face for the preview of the replacement text. +Used while reading the replacement string of `query-replace' and +friends when `query-replace-show-preview' is non-nil." + :group 'matching + :version "32.1") + (defvar replace-count 0 "Number of replacements done so far. See `replace-regexp'.") @@ -298,13 +316,19 @@ query-replace-read-from (add-to-history 'query-replace-defaults (cons from to) nil t) (cons from (query-replace-compile-replacement to regexp-flag)))))) +(defconst query-replace-eval-replacement-regexp + "\\(\\`\\|[^\\]\\)\\(\\\\\\\\\\)*\\\\[,#]" + "Regexp matching a replacement string that needs to be evaluated. +This matches the replacement strings that use \\, or \\#, and thus +have to be converted to Lisp by `query-replace-compile-replacement'.") + (defun query-replace-compile-replacement (to regexp-flag) "Maybe convert a regexp replacement TO to Lisp. REGEXP-FLAG non-nil means TO is a regexp. Returns a list suitable for `perform-replace' if necessary, the original string if not." (if (and regexp-flag - (string-match "\\(\\`\\|[^\\]\\)\\(\\\\\\\\\\)*\\\\[,#]" to)) + (string-match query-replace-eval-replacement-regexp to)) (let (pos list char) (while (progn @@ -330,7 +354,7 @@ query-replace-compile-replacement (1+ (cdr pos)) (cdr pos)))) (setq to (substring to end))))) - (string-match "\\(\\`\\|[^\\]\\)\\(\\\\\\\\\\)*\\\\[,#]" to))) + (string-match query-replace-eval-replacement-regexp to))) (setq to (nreverse (delete "" (cons to list)))) (replace-match-string-symbols to) (cons #'replace-eval-replacement @@ -340,17 +364,126 @@ query-replace-compile-replacement to)) -(defun query-replace-read-to (from prompt regexp-flag) +(defvar replace-preview-overlays nil + "List of overlays used to preview the replacement text.") + +(defun replace-preview-cleanup () + "Remove the overlays that preview the replacement text." + (mapc #'delete-overlay replace-preview-overlays) + (setq replace-preview-overlays nil)) + +(defun replace-preview-update (from to regexp-flag delimited-flag case-fold) + "Preview the result of replacing FROM with TO in the current buffer. +Each match of FROM visible in the selected window is displayed as the +text it would be replaced with, using the `query-replace-preview' face. +REGEXP-FLAG, DELIMITED-FLAG and CASE-FOLD say how to search for FROM, +as in `replace-search'." + (replace-preview-cleanup) + (let ((nocasify (not (and case-replace case-fold))) + (literal (or (not regexp-flag) (eq regexp-flag 'literal))) + (limit (window-end nil t))) + (save-excursion + (save-match-data + (goto-char (window-start)) + (while (and (< (point) limit) + (replace-search from limit regexp-flag delimited-flag + case-fold)) + (let* ((beg (match-beginning 0)) + (end (match-end 0)) + (text (propertize (match-substitute-replacement + to nocasify literal) + 'face 'query-replace-preview))) + (when (funcall isearch-filter-predicate beg end) + (let ((ov (make-overlay beg end))) + ;; A zero-length overlay displays nothing, so for an + ;; empty match show the replacement next to it instead. + (if (= beg end) + (overlay-put ov 'before-string text) + (overlay-put ov 'display text)) + (overlay-put ov 'priority 1001) ;higher than lazy overlays + (push ov replace-preview-overlays))) + ;; Don't loop forever on a zero-length match. + (when (and (= beg end) (not (eobp))) + (forward-char 1)))))))) + +(defun replace-preview-setup (from regexp-flag delimited-flag) + "Return a closure that previews the replacement of FROM. +Add it to `minibuffer-setup-hook' while reading the replacement text: +on every change it shows, in the original window, how the visible +matches of FROM would look after the replacement. +REGEXP-FLAG and DELIMITED-FLAG say how to search for FROM, as in +`replace-search'." + (if (or (not query-replace-show-preview) (minibufferp)) + #'ignore + (let ((unwind (make-symbol "replace-preview--unwind")) + (after-change (make-symbol "replace-preview--after-change")) + (buffer (current-buffer)) + (case-fold (if (and case-fold-search search-upper-case) + (isearch-no-upper-case-p from regexp-flag) + case-fold-search)) + (region-filter (when (use-region-p) + (replace--region-filter + (funcall region-extract-function 'bounds))))) + (fset unwind + (lambda () + (remove-hook 'after-change-functions after-change t) + (remove-hook 'minibuffer-exit-hook unwind t) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when region-filter + (remove-function (local 'isearch-filter-predicate) + region-filter)) + (replace-preview-cleanup))))) + (fset after-change + (lambda (_beg _end _len) + (let ((to (minibuffer-contents-no-properties))) + (with-minibuffer-selected-window + ;; The replacement text is typed one character at a + ;; time, so it's expected to be invalid meanwhile, + ;; e.g. when it ends with a backslash or refers to a + ;; group that the regexp doesn't have. + (condition-case nil + (if (and regexp-flag + (string-match + query-replace-eval-replacement-regexp to)) + ;; Neither \, nor \# can be previewed, for + ;; different reasons. \, is a Lisp expression + ;; that the user is still typing: evaluating it + ;; on each keystroke would run the side effects + ;; of a half-typed form as soon as it happens + ;; to be readable. \# expands to the number of + ;; replacements made so far, and none has been + ;; made yet, so the preview would show 0 for + ;; every match where the replacement itself + ;; will show 0, 1, 2... + (replace-preview-cleanup) + (replace-preview-update from to regexp-flag + delimited-flag case-fold)) + (error (replace-preview-cleanup))))))) + (lambda () + (add-hook 'minibuffer-exit-hook unwind nil t) + (add-hook 'after-change-functions after-change nil t) + (when region-filter + (with-current-buffer buffer + (add-function :after-while (local 'isearch-filter-predicate) + region-filter))) + (funcall after-change nil nil nil))))) + +(defun query-replace-read-to (from prompt regexp-flag &optional delimited-flag) "Query and return the TO argument of a `query-replace' operation. Prompt with PROMPT. REGEXP-FLAG non-nil means the response -should a regexp." +should a regexp. +DELIMITED-FLAG is used to search for the occurrences of FROM when +previewing the replacement (see `query-replace-show-preview')." (query-replace-compile-replacement (save-excursion (let* ((history-add-new-input nil) - (to (read-from-minibuffer - (format "%s %s with: " prompt (query-replace-descr from)) - nil nil nil - query-replace-to-history-variable from t))) + (to (minibuffer-with-setup-hook + (replace-preview-setup from regexp-flag delimited-flag) + (read-from-minibuffer + (format "%s %s with: " prompt (query-replace-descr from)) + nil nil nil + query-replace-to-history-variable from t)))) (add-to-history query-replace-to-history-variable to nil t) (add-to-history 'query-replace-defaults (cons from to) nil t) to)) @@ -386,7 +519,8 @@ query-replace-read-args from-string))) (query-replace-read-from prompt regexp-flag))) (to (if (consp from) (prog1 (cdr from) (setq from (car from))) - (query-replace-read-to from prompt regexp-flag)))) + (query-replace-read-to from prompt regexp-flag + delimited-flag)))) (list from to (or delimited-flag (and (plist-member (text-properties-at 0 from) 'isearch-regexp-function) diff --git a/test/lisp/replace-tests.el b/test/lisp/replace-tests.el index 15140ca46c5..ae27e3e4b67 100644 --- a/test/lisp/replace-tests.el +++ b/test/lisp/replace-tests.el @@ -705,6 +705,55 @@ replace-regexp-bug45973 (if (match-string 2) "R" "L")))) (should (equal (buffer-string) after))))) +(defun replace-tests--preview (text from to regexp-flag &optional case-fold) + "Return the previews of replacing FROM with TO in a buffer holding TEXT. +Each preview is a list (BEG END STRING)." + (with-temp-buffer + (insert text) + (set-window-buffer (selected-window) (current-buffer)) + (unwind-protect + (progn + (replace-preview-update from to regexp-flag nil case-fold) + (mapcar (lambda (ov) + (list (overlay-start ov) + (overlay-end ov) + (substring-no-properties + (or (overlay-get ov 'display) + (overlay-get ov 'before-string))))) + (reverse replace-preview-overlays))) + (replace-preview-cleanup)))) + +(ert-deftest replace-tests-preview-update () + ;; Back-references are expanded in the preview. + (should (equal (replace-tests--preview "foo1 foo2\n" "foo\\([0-9]\\)" + "bar-\\1" t) + '((1 5 "bar-1") (6 10 "bar-2")))) + ;; So is the whole match. + (should (equal (replace-tests--preview "abc\n" "b" "[\\&]" t) + '((2 3 "[b]")))) + ;; The preview adapts the case like the replacement itself does. + (should (equal (replace-tests--preview "Foo foo\n" "foo" "bar" nil t) + '((1 4 "Bar") (5 8 "bar")))) + ;; An empty match is previewed with a zero-length overlay. + (should (equal (replace-tests--preview "ab\n" "x*" "Z" t) + '((1 1 "Z") (2 2 "Z") (3 3 "Z"))))) + +(ert-deftest replace-tests-preview-cleanup () + (with-temp-buffer + (insert "foo foo\n") + (set-window-buffer (selected-window) (current-buffer)) + (replace-preview-update "foo" "bar" nil nil nil) + (should replace-preview-overlays) + (replace-preview-cleanup) + (should-not replace-preview-overlays) + (should-not (overlays-in (point-min) (point-max))))) + +(ert-deftest replace-tests-preview-disabled () + (let ((query-replace-show-preview nil)) + (should (eq (replace-preview-setup "foo" nil nil) #'ignore))) + (let ((query-replace-show-preview t)) + (should-not (eq (replace-preview-setup "foo" nil nil) #'ignore)))) + (ert-deftest test-count-matches () (with-temp-buffer (insert "oooooooooo") -- 2.55.0