;;; persistent-scratch.el --- Preserve the scratch buffer across Emacs sessions -*- lexical-binding: t -*-

;; Author: Fanael Linithien <fanael4@gmail.com>
;; URL: https://github.com/Fanael/persistent-scratch
;; Package-Commit: 5ff41262f158d3eb966826314516f23e0cb86c04
;; Package-Version: 20230225.1439
;; Package-X-Original-Version: 0.3.9
;; Package-Requires: ((emacs "24"))

;; This file is NOT part of GNU Emacs.

;; Copyright (c) 2015-2023, Fanael Linithien
;; All rights reserved.
;;
;; Redistribution and use in source and binary forms, with or without
;; modification, are permitted provided that the following conditions are
;; met:
;;
;;   * Redistributions of source code must retain the above copyright
;;     notice, this list of conditions and the following disclaimer.
;;   * Redistributions in binary form must reproduce the above copyright
;;     notice, this list of conditions and the following disclaimer in the
;;     documentation and/or other materials provided with the distribution.
;;
;; THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
;; IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
;; TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
;; PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
;; OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
;; EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
;; PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
;; PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
;; LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

;;; Commentary:

;; Preserve the state of scratch buffers across Emacs sessions by saving the
;; state to and restoring it from a file, with autosaving and backups.
;;
;; Save scratch buffers: `persistent-scratch-save' and
;; `persistent-scratch-save-to-file'.
;; Restore saved state: `persistent-scratch-restore' and
;; `persistent-scratch-restore-from-file'.
;;
;; To control where the state is saved, set `persistent-scratch-save-file'.
;; What exactly is saved is determined by `persistent-scratch-what-to-save'.
;; What buffers are considered scratch buffers is determined by
;; `persistent-scratch-scratch-buffer-p-function'. By default, only the
;; `*scratch*' buffer is a scratch buffer.
;;
;; Autosave can be enabled by turning `persistent-scratch-autosave-mode' on.
;;
;; Backups of old saved states are off by default, set
;; `persistent-scratch-backup-directory' to a directory to enable them.
;;
;; To both enable autosave and restore the last saved state on Emacs start, add
;;   (persistent-scratch-setup-default)
;; to the init file. This will NOT error when the save file doesn't exist.
;;
;; To just restore on Emacs start, it's a good idea to call
;; `persistent-scratch-restore' inside an `ignore-errors' or
;; `with-demoted-errors' block.

;;; Code:
(eval-when-compile (require 'pcase))

(defgroup persistent-scratch nil
  "Preserve the state of scratch buffers across Emacs sessions."
  :group 'files
  :prefix "persistent-scratch-")

(defcustom persistent-scratch-scratch-buffer-p-function
  #'persistent-scratch-default-scratch-buffer-p
  "Function determining whether the current buffer is a scratch buffer.
When this function, called with no arguments, returns non-nil, the current
buffer is assumed to be a scratch buffer, thus becoming eligible for
\(auto-)saving."
  :type 'function
  :group 'persistent-scratch)

(defcustom persistent-scratch-save-file
  (expand-file-name ".persistent-scratch" user-emacs-directory)
  "File to save to the scratch buffers to."
  :type 'file
  :group 'persistent-scratch)

(defcustom persistent-scratch-before-save-commit-functions '()
  "Abnormal hook for performing operations before committing a save file.

Functions are called with one argument TEMP-FILE: the path of the
temporary file containing uncommitted save data, which will be moved to
`persistent-scratch-save-file' after the hook runs.

The intended use of this hook is to allow changing the file system
permissions of the file before committing."
  :type 'hook
  :group 'persistent-scratch)

(defcustom persistent-scratch-what-to-save
  '(major-mode point narrowing)
  "Specify what scratch buffer properties to save.

The buffer name and the buffer contents are always saved.

It's a list containing some or all of the following values:
 - `major-mode': save the major mode.
 - `point': save the positions of `point' and `mark'.
 - `narrowing': save the region the buffer is narrowed to.
 - `text-properties': save the text properties of the buffer contents."
  :type '(repeat :tag "What to save"
                 (choice :tag "State to save"
                         (const :tag "Major mode"
                                major-mode)
                         (const :tag "Point and mark"
                                point)
                         (const :tag "Narrowing"
                                narrowing)
                         (const :tag "Text properties of contents"
                                text-properties)))
  :group 'persistent-scratch)

(defcustom persistent-scratch-autosave-interval 300
  "The interval, in seconds, between autosaves of scratch buffers.

Can be either a number N, in which case scratch buffers are saved every N
seconds, or a cons cell (`idle' . N), in which case scratch buffers are saved
every time Emacs becomes idle for at least N seconds.

Setting this variable when `persistent-scratch-autosave-mode' is already on does
nothing, call `persistent-scratch-autosave-mode' for it to take effect."
  :type '(radio number
                (cons :tag "When idle for" (const idle) number))
  :group 'persistent-scratch)

(defcustom persistent-scratch-backup-directory nil
  "Directory to save old versions of scratch buffer saves to.
When nil, backups are disabled."
  :type '(choice directory
                 (const :tag "Disabled" nil))
  :group 'persistent-scratch)

(defcustom persistent-scratch-backup-filter #'ignore
  "Function returning the list of file names of old backups to delete.
By default, no backups are deleted.
This function is called with one argument, a list of file names in
`persistent-scratch-backup-directory'; this list is *not* sorted in any way."
  :type 'function
  :group 'persistent-scratch)

(defcustom persistent-scratch-backup-file-name-format "%Y-%m-%d--%H-%M-%S-%N"
  "Format of backup file names, for `format-time-string'."
  :type 'string
  :group 'persistent-scratch)

;;;###autoload
(defun persistent-scratch-save (&optional file)
  "Save the current state of scratch buffers.
When FILE is non-nil, the state is saved to FILE; when nil or when called
interactively, the state is saved to `persistent-scratch-save-file'.
What state exactly is saved is determined by `persistent-scratch-what-to-save'.

When FILE is nil and `persistent-scratch-backup-directory' is non-nil, a copy of
`persistent-scratch-save-file' is stored in that directory, with a name
representing the time of the last `persistent-scratch-new-backup' call."
  (interactive)
  (let* ((actual-file (or file persistent-scratch-save-file))
         (tmp-file (concat actual-file ".new"))
         (saved-state (persistent-scratch--save-buffers-state)))
    (let ((old-umask (default-file-modes)))
      (set-default-file-modes #o600)
      (unwind-protect
          (let ((coding-system-for-write 'utf-8-unix))
            (write-region (cdr saved-state) nil tmp-file nil 0))
        (set-default-file-modes old-umask)))
    (run-hook-with-args 'persistent-scratch-before-save-commit-functions tmp-file)
    (rename-file tmp-file actual-file t)
    (dolist (buffer (car saved-state))
      (with-current-buffer buffer
        (set-buffer-modified-p nil)))
    (when (called-interactively-p 'interactive)
      (message "Wrote persistent-scratch file %s" actual-file)))
  (unless file
    (persistent-scratch--update-backup)
    (persistent-scratch--cleanup-backups)))

;;;###autoload
(defun persistent-scratch-save-to-file (file)
  "Save the current state of scratch buffers.
The state is saved to FILE.

When called interactively, prompt for the file name, which is the only
difference between this function and `persistent-scratch-save'.

See `persistent-scratch-save'."
  (interactive "F")
  (persistent-scratch-save file))

;;;###autoload
(defun persistent-scratch-restore (&optional file)
  "Restore the scratch buffers.
Load FILE and restore all saved buffers to their saved state.

FILE is a file to restore scratch buffers from; when nil or when called
interactively, `persistent-scratch-save-file' is used.

This is a potentially destructive operation: if there's an open buffer with the
same name as a saved buffer, the contents of that buffer will be overwritten."
  (interactive)
  (let ((save-data
         (read
          (with-temp-buffer
            (let ((coding-system-for-read 'utf-8-unix))
              (insert-file-contents (or file persistent-scratch-save-file)))
            (buffer-string)))))
    (dolist (saved-buffer save-data)
      (with-current-buffer (get-buffer-create (aref saved-buffer 0))
        (erase-buffer)
        (insert (aref saved-buffer 1))
        (funcall (or (aref saved-buffer 3) #'ignore))
        (let ((point-and-mark (aref saved-buffer 2)))
          (when point-and-mark
            (goto-char (car point-and-mark))
            (set-mark (cdr point-and-mark))))
        (let ((narrowing (aref saved-buffer 4)))
          (when narrowing
            (narrow-to-region (car narrowing) (cdr narrowing))))
        ;; Handle version 2 fields if present.
        (when (>= (length saved-buffer) 6)
          (unless (aref saved-buffer 5)
            (deactivate-mark)))))))

;;;###autoload
(defun persistent-scratch-restore-from-file (file)
  "Restore the scratch buffers from a file.
FILE is a file storing saved scratch buffer state.

When called interactively, prompt for the file name, which is the only
difference between this function and `persistent-scratch-restore'.

See `persistent-scratch-restore'."
  (interactive "f")
  (persistent-scratch-restore file))

(defvar persistent-scratch--auto-restored nil)

(defun persistent-scratch--auto-restore ()
  "Automatically restore the scratch buffer once per session."
  (unless persistent-scratch--auto-restored
    (condition-case err
        (persistent-scratch-restore)
      (error
       (message "Failed to restore scratch buffers: %S" err)
       nil))
    (setq persistent-scratch--auto-restored t)))

(defvar persistent-scratch-mode-map
  (let ((m (make-sparse-keymap)))
    (define-key m [remap save-buffer] 'persistent-scratch-save)
    (define-key m [remap write-file] 'persistent-scratch-save-to-file)
    m)
  "The keymap for `persistent-scratch-mode'.")

;;;###autoload
(define-minor-mode persistent-scratch-mode
  "Utility mode that remaps `save-buffer' and `write-file' to their
`persistent-scratch' equivalents.

This mode cannot be enabled in buffers for which
`persistent-scratch-scratch-buffer-p-function' is nil.

\\{persistent-scratch-mode-map}"
  :lighter " PS"
  (when (and persistent-scratch-mode
             (not (funcall persistent-scratch-scratch-buffer-p-function)))
    (setq persistent-scratch-mode nil)
    (error
     "This buffer isn't managed by `persistent-scratch', not enabling mode.")))

;;;###autoload
(define-minor-mode persistent-scratch-autosave-mode
  "Autosave scratch buffer state.
Every `persistent-scratch-autosave-interval' seconds and when Emacs quits, the
state of all active scratch buffers is saved.
This uses `persistent-scratch-save', which see.

Toggle Persistent-Scratch-Autosave mode on or off.
With a prefix argument ARG, enable Persistent-Scratch-Autosave mode if ARG is
positive, and disable it otherwise. If called from Lisp, enable the mode if ARG
is omitted or nil, and toggle it if ARG is `toggle'.
\\{persistent-scratch-autosave-mode-map}"
  :init-value nil
  :lighter ""
  :keymap nil
  :global t
  (persistent-scratch--auto-restore)
  (persistent-scratch--turn-autosave-off)
  (when persistent-scratch-autosave-mode
    (persistent-scratch--turn-autosave-on)))

(defvar persistent-scratch--current-backup-time (current-time))

;;;###autoload
(defun persistent-scratch-new-backup ()
  "Create a new scratch buffer save backup file.
The next time `persistent-scratch-save' is called, it will create a new backup
file and use that file from now on."
  (interactive)
  (setq persistent-scratch--current-backup-time (current-time)))

;;;###autoload
(defun persistent-scratch-setup-default ()
  "Enable `persistent-scratch-autosave-mode' and restore the scratch buffers.
When an error occurs while restoring the scratch buffers, it's demoted to a
message."
  (persistent-scratch--auto-restore)
  (persistent-scratch-autosave-mode))

(defun persistent-scratch-default-scratch-buffer-p ()
  "Return non-nil iff the current buffer's name is *scratch*."
  (string= (buffer-name) "*scratch*"))

;;;###autoload
(defun persistent-scratch-keep-n-newest-backups (n)
  "Return a backup filter that keeps N newest backups.
The returned function is suitable for `persistent-scratch-backup-filter'.

Note: this function assumes that increasing time values result in
lexicographically increasing file names when formatted using
`persistent-scratch-backup-file-name-format'."
  (lambda (files)
    (nthcdr n (sort files (lambda (a b) (string-lessp b a))))))

;;;###autoload
(defun persistent-scratch-keep-backups-not-older-than (diff)
  "Return a backup filter that keeps backups newer than DIFF.
DIFF may be either a number representing the number of second, or a time value
in the format returned by `current-time' or `seconds-to-time'.
The returned function is suitable for `persistent-scratch-backup-filter'.

Note: this function assumes that increasing time values result in
lexicographically increasing file names when formatted using
`persistent-scratch-backup-file-name-format'."
  (when (numberp diff)
    (setq diff (seconds-to-time diff)))
  (lambda (files)
    (let ((limit (format-time-string persistent-scratch-backup-file-name-format
                                     (time-subtract (current-time) diff))))
      (delq nil (mapcar (lambda (file)
                          (when (string-lessp file limit)
                            file))
                        files)))))

(defun persistent-scratch--save-buffers-state ()
  "Save the current state of scratch buffers.

The returned value is a cons cell (BUFFER-LIST . STATE-STRING)."
  (let ((buffers '())
        (save-data '()))
    (dolist (buffer (buffer-list))
      (with-current-buffer buffer
        (when (funcall persistent-scratch-scratch-buffer-p-function)
          (push buffer buffers)
          (push (persistent-scratch--get-buffer-state) save-data))))
    (let ((print-quoted t)
          (print-circle t)
          (print-gensym t)
          (print-escape-newlines nil)
          (print-length nil)
          (print-level nil))
      (cons buffers (prin1-to-string save-data)))))

;; Compatibility shim for Emacs 24.{1, 2}
(defalias 'persistent-scratch-buffer-narrowed-p
  (if (fboundp 'buffer-narrowed-p)
      #'buffer-narrowed-p
    (lambda ()
      "Return non-nil if the current buffer is narrowed."
      (< (- (point-min) (point-max)) (buffer-size)))))

(defun persistent-scratch--get-buffer-state ()
  "Get an object representing the current buffer save state.
The returned object is printable and readable.
The exact format is undocumented, but must be kept in sync with what
`persistent-scratch-restore' expects."
  (vector
   ;; Version 1 fields.
   (buffer-name)
   (save-restriction
     (widen)
     (if (memq 'text-properties persistent-scratch-what-to-save)
         (buffer-string)
       (buffer-substring-no-properties 1 (1+ (buffer-size)))))
   (when (memq 'point persistent-scratch-what-to-save)
     (cons (point) (ignore-errors (mark))))
   (when (memq 'major-mode persistent-scratch-what-to-save)
     major-mode)
   (when (and (persistent-scratch-buffer-narrowed-p)
              (memq 'narrowing persistent-scratch-what-to-save))
     (cons (point-min) (point-max)))
   ;; Version 2 fields.
   (when (memq 'point persistent-scratch-what-to-save)
     (or (not transient-mark-mode) (region-active-p)))))

(defun persistent-scratch--update-backup ()
  "Copy the save file to the backup directory."
  (when persistent-scratch-backup-directory
    (let ((original-name persistent-scratch-save-file)
          (new-name
           (let ((file-name
                  (format-time-string
                   persistent-scratch-backup-file-name-format
                   persistent-scratch--current-backup-time)))
             (expand-file-name file-name persistent-scratch-backup-directory))))
      (make-directory persistent-scratch-backup-directory t)
      (copy-file original-name new-name t nil t t))))

(defun persistent-scratch--cleanup-backups ()
  "Clean up old backups.
It's done by calling `persistent-scratch-backup-filter' on a list of all files
in the backup directory and deleting all returned file names."
  (when persistent-scratch-backup-directory
    (let* ((directory
            (file-name-as-directory persistent-scratch-backup-directory))
           (file-names (directory-files directory nil nil t)))
      (setq file-names (delq nil (mapcar (lambda (name)
                                           (unless (member name '("." ".."))
                                             name))
                                         file-names)))
      (dolist (file-to-delete
               (funcall persistent-scratch-backup-filter file-names))
        (delete-file (concat directory file-to-delete))))))

(defvar persistent-scratch--autosave-timer nil)

(defun persistent-scratch--turn-autosave-off ()
  "Turn `persistent-scratch-autosave-mode' off."
  (remove-hook 'kill-emacs-hook #'persistent-scratch-save)
  (when persistent-scratch--autosave-timer
    (cancel-timer persistent-scratch--autosave-timer)
    (setq persistent-scratch--autosave-timer nil)))

(defun persistent-scratch--turn-autosave-on ()
  "Turn `persistent-scratch-autosave-mode' on."
  (add-hook 'kill-emacs-hook #'persistent-scratch-save)
  (setq persistent-scratch--autosave-timer
        (pcase persistent-scratch-autosave-interval
          (`(idle . ,x) (run-with-idle-timer x x #'persistent-scratch-save))
          (x (run-with-timer x x #'persistent-scratch-save)))))

(provide 'persistent-scratch)
;;; persistent-scratch.el ends here
