Skip to main content

Using Occur for Search and Replace across Files

This post describes how to combine occur with query-replace to search and replace across files and contains some useful Elisp snippets and recommendations to improve this workflow.

This post is a follow up of the more general post about search and replacement techniques in Emacs.

Search and replace in projects

To search with occur across files in projects I recommend the great noccur package. It’s a small package that provides some convenient commands which simplify using the built-in multi-occur for projects and from dired.

Show context on replace

After a search via M-x noccur-project or multi-occur or any other occur variant, you get an occur buffer with results grouped by files the matches where found in. In occur you can enter occur-edit-mode with e which makes the search results editable.

After you entered occur-edit-mode you can use query-replace or any editing commands you like to edit the search results. I prefer to see some context before replacing a match which can be done by setting up some hooks:

(add-hook 'occur-mode-hook
          (defun occur-show-replace-context+ ()
            (add-hook 'replace-update-post-hook
                      'occur-mode-display-occurrence nil 'local)))

The above adds occur-mode-display-occurrence to replace-update-post-hook in occur buffers, this way you always see the context when you are queried for the replace.

One problem is that occur-mode-display-occurrence messes with the match data (Emacs 26.3) which breaks query-replace. To fix this you need to use the following advice:

(define-advice occur-mode-display-occurrence
    (:around (fun &rest args) save-match-data)
    (apply fun args)))

Skip occur title lines for search and replace

By default query-replace and isearch won’t skip the header lines which show match info and therefore shouldn’t be included. To fix this you can use the following snippet:

(add-hook 'occur-mode-hook
          (defun isearch-occur-setup+ ()
            (add-function :after-while (local 'isearch-filter-predicate)

(defun occur-isearch-filter-p+ (beg end)
  "Return non-nil if match should be considered."
     ;; always omit first line wich contains summary info and in multi-occur it
     ;; has no occur-title property and isn't read only
     (goto-char beg)
     (not (= (point-min) (line-beginning-position))))
   ;; omit the other occur header lines
   (not (or (get-text-property beg 'occur-title)
            (get-text-property end 'occur-title)))))

The above adds a local filter predicate for occur which takes care of omitting the header lines for search and replace operations. To learn more about how this works you can find more info in the docstrings of add-function and isearch-filter-predicate.

You could also set the variable isearch-filter-predicate buffer locally using setq-local but using add-function like shown above has the benefit of automatically respecting other filter predicates you might have setup.

Auto-save edits

After you are done with your edits you can get back to regular occur-mode with C-c C-c. The modified buffers are not automatically saved to disk but if you would like to do that you can use the following:

(defvar-local occur-edit-buffers+ nil
  "Save buffers in which occur performed changes.")

(add-hook 'occur-edit-mode-hook
          (defun occur-eddit-save-edits+ ()
            (add-hook 'after-change-functions
                      'occur-edit-remember-buffer+ nil t)))

(defun occur-edit-remember-buffer+ (&rest _)
  (let* ((m (get-text-property (line-beginning-position) 'occur-target))
         (buf (and (markerp m) (marker-buffer m))))
    (when buf
      (pushnew buf occur-edit-buffers+))))

(define-advice occur-cease-edit (:before () save-edits)
  (dolist (buf occur-edit-buffers+)
    (with-current-buffer buf