;;; fileloop.el --- Operations on multiple files  -*- lexical-binding: t; -*-

;; Copyright (C) 2018-2023 Free Software Foundation, Inc.

;; Author: Stefan Monnier <monnier@iro.umontreal.ca>

;; This file is part of GNU Emacs.

;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Support functions for operations like search or query&replace applied to
;; several files.  This code was largely inspired&extracted from an earlier
;; version of etags.el.

;; TODO:
;; - Maybe it would make sense to replace the fileloop--* vars with a single
;;   global var holding a struct, and then stash those structs into a history
;;   of past operations, so you can perform a fileloop-search while in the
;;   middle of a fileloop-replace and later go back to that
;;   fileloop-replace.
;; - Make multi-isearch work on top of this library (might require changes
;;   to this library, of course).

;;; Code:

(require 'generator)

(defgroup fileloop nil
  "Operations on multiple files."
  :group 'tools)

(defcustom fileloop-revert-buffers 'silent
  "Whether to revert files during fileloop operation.
This can be one of:
  `silent' means to only do it if `revert-without-query' is applicable;
  t        means to offer to do it for all applicable files;
  nil      means never to do it"
  :type '(choice (const silent) (const t) (const nil)))

;; FIXME: This already exists in GNU ELPA's iterator.el.  Maybe it should move
;; to generator.el?
(iter-defun fileloop--list-to-iterator (list)
  (while list (iter-yield (pop list))))

(defvar fileloop--iterator iter-empty)
(defvar fileloop--scan-function
  (lambda () (user-error "No operation in progress")))
;; If the default value below is changed, the :enable form of
;; tags-continue and tags-repl-continue in menu-bar.el will have to be
;; updated accordingly.
(defvar fileloop--operate-function #'ignore)
(defvar fileloop--freshly-initialized nil)

;;;###autoload
(defun fileloop-initialize (files scan-function operate-function)
  "Initialize a new round of operation on several files.
FILES can be either a list of file names, or an iterator (used with `iter-next')
which returns a file name at each step.
SCAN-FUNCTION is a function called with no argument inside a buffer
and it should return non-nil if that buffer has something on which to operate.
OPERATE-FUNCTION is a function called with no argument; it is expected
to perform the operation on the current file buffer and when done
should return non-nil to mean that we should immediately continue
operating on the next file and nil otherwise."
  (setq fileloop--iterator
        (if (and (listp files) (not (functionp files)))
            (fileloop--list-to-iterator files)
          files))
  (setq fileloop--scan-function scan-function)
  (setq fileloop--operate-function operate-function)
  (setq fileloop--freshly-initialized t))

(defun fileloop-next-file (&optional novisit)
  ;; FIXME: Should we provide an interactive command, like tags-next-file?
  (let ((next (condition-case nil
                  (iter-next fileloop--iterator)
                (iter-end-of-sequence nil))))
    (unless next
      (and novisit
	   (get-buffer " *next-file*")
	   (kill-buffer " *next-file*"))
      (user-error "All files processed"))
    (let* ((buffer (get-file-buffer next))
	   (new (not buffer)))
      ;; Optionally offer to revert buffers
      ;; if the files have changed on disk.
      (and buffer fileloop-revert-buffers
	   (not (verify-visited-file-modtime buffer))
           (if (eq fileloop-revert-buffers 'silent)
               (and (not (buffer-modified-p buffer))
                    (let ((revertible nil))
                      (dolist (re revert-without-query)
                        (when (string-match-p re next)
                          (setq revertible t)))
                      revertible))
	     (y-or-n-p
	      (format
	       (if (buffer-modified-p buffer)
	           "File %s changed on disk.  Discard your edits? "
	         "File %s changed on disk.  Reread from disk? ")
	       next)))
	   (with-current-buffer buffer
	     (revert-buffer t t)))
      (if (not (and new novisit))
	  (set-buffer (find-file-noselect next))
        ;; Like find-file, but avoids random warning messages.
        (set-buffer (get-buffer-create " *next-file*"))
        (kill-all-local-variables)
        (erase-buffer)
        (setq new next)
        (condition-case nil
            (insert-file-contents new nil)
          (file-missing
           (fileloop-next-file novisit))))
      new)))

(defun fileloop-continue ()
  "Continue last multi-file operation."
  (interactive)
  (let (new
	;; Non-nil means we have finished one file
	;; and should not scan it again.
	file-finished
	original-point
	(messaged nil))
    (while
	(progn
	  ;; Scan files quickly for the first or next interesting one.
	  ;; This starts at point in the current buffer.
	  (while (or fileloop--freshly-initialized file-finished
		     (save-restriction
		       (widen)
		       (not (funcall fileloop--scan-function))))
	    ;; If nothing was found in the previous file, and
	    ;; that file isn't in a temp buffer, restore point to
	    ;; where it was.
	    (when original-point
	      (goto-char original-point))

	    (setq file-finished nil)
	    (setq new (fileloop-next-file t))

	    ;; If NEW is non-nil, we got a temp buffer,
	    ;; and NEW is the file name.
	    (when (or messaged
		      (and (not fileloop--freshly-initialized)
			   (> baud-rate search-slow-speed)
			   (setq messaged t)))
	      (message "Scanning file %s..." (or new buffer-file-name)))

	    (setq fileloop--freshly-initialized nil)
	    (setq original-point (if new nil (point)))
	    (goto-char (point-min)))

	  ;; If we visited it in a temp buffer, visit it now for real.
	  (if new
	      (let ((pos (point)))
		(erase-buffer)
		(set-buffer (find-file-noselect new))
		(setq new nil)		;No longer in a temp buffer.
		(widen)
		(goto-char pos))
	    (push-mark original-point t))

          (let (switch-to-buffer-preserve-window-point)
	    (switch-to-buffer (current-buffer)))

	  ;; Now operate on the file.
	  ;; If value is non-nil, continue to scan the next file.
          (save-restriction
            (widen)
            (funcall fileloop--operate-function)))
      (setq file-finished t))))

;;;###autoload
(defun fileloop-initialize-search (regexp files case-fold)
  (let ((last-buffer (current-buffer)))
    (fileloop-initialize
     files
     (lambda ()
       (let ((case-fold-search (fileloop--case-fold regexp case-fold)))
         (re-search-forward regexp nil t)))
     (lambda ()
       (unless (eq last-buffer (current-buffer))
         (setq last-buffer (current-buffer))
         (message "Scanning file %s...found" buffer-file-name))
       nil))))

(defun fileloop--case-fold (regexp case-fold)
  (let ((value
         (if (memql case-fold '(nil t))
             case-fold
           case-fold-search)))
    (if (and value search-upper-case)
        (isearch-no-upper-case-p regexp t)
      value)))

;;;###autoload
(defun fileloop-initialize-replace (from to files case-fold &optional delimited)
  "Initialize a new round of query&replace on several files.
FROM is a regexp and TO is the replacement to use.
FILES describes the files, as in `fileloop-initialize'.
CASE-FOLD can be t, nil, or `default':
  if it is nil, matching of FROM is case-sensitive.
  if it is t, matching of FROM is case-insensitive, except
     when `search-upper-case' is non-nil and FROM includes
     upper-case letters.
  if it is `default', the function uses the value of
     `case-fold-search' instead.
DELIMITED if non-nil means replace only word-delimited matches."
  ;; FIXME: Not sure how the delimited-flag interacts with the regexp-flag in
  ;; `perform-replace', so I just try to mimic the old code.
  (let ((mstart (make-hash-table :test 'eq)))
    (fileloop-initialize
     files
     (lambda ()
       (let ((case-fold-search (fileloop--case-fold from case-fold)))
         (when (re-search-forward from nil t)
           ;; When we find a match, save its beginning for
           ;; `perform-replace' (we used to just set point, but this
           ;; is unreliable in the face of
           ;; `switch-to-buffer-preserve-window-point').
           (puthash (current-buffer) (match-beginning 0) mstart))))
     (lambda ()
       (let ((case-fold-search (fileloop--case-fold from case-fold)))
         (perform-replace from to t t delimited nil multi-query-replace-map
                          (gethash (current-buffer) mstart (point-min))
                          (point-max)))))))

(provide 'fileloop)
;;; fileloop.el ends here
