;;; persist.el --- Persist Variables between Emacs Sessions -*- lexical-binding: t -*-

;; Copyright (C) 2019, 2024, 2025 Free Software Foundation, Inc.

;; Author: Phillip Lord <phillip.lord@russet.org.uk>
;; Maintainer: Joseph Turner <persist-el@breatheoutbreathe.in>
;; Package-Type: multi
;; Package-Requires: ((emacs "26.1") (compat "30.0.2.0"))
;; Version: 0.8

;; The contents of this file are subject to the GPL License, Version 3.0.

;; This file is not part of Emacs

;; This program 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.

;; This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; This package provides variables which persist across sessions.

;; The main entry point is `persist-defvar' which behaves like
;; `defvar' but which persists the variables between session.  Variables
;; are automatically saved when Emacs exits.

;; Other useful functions are `persist-save' which saves the variable
;; immediately, `persist-load' which loads the saved value,
;; `persist-reset' which resets to the default value.

;; Values are stored in a directory in `user-emacs-directory', using
;; one file per value.  This makes it easy to delete or remove unused
;; variables.

;;; Code:

(require 'compat)

(defvar persist--directory-location
  (locate-user-emacs-file "persist")
  "The location of persist directory.")

(defvar persist--symbols nil
  "List of symbols to persist.")

(defvar persist-load-hook nil
  "Special hook run on loading a variable.

Hook functions are called with two values: the symbol and the
value it will be set to.  If any function returns nil, the
variable is not set to the value.")

(defun persist--file-location (symbol)
  "Return the file name at which SYMBOL does or will persist."
  (expand-file-name
   (symbol-name symbol)
   (or (get symbol 'persist-location)
       persist--directory-location)))

(defun persist--defvar-1 (symbol location initvalue)
  "Set symbol up for persistence."
  (when location
    (persist-location symbol location))
  (persist-symbol symbol initvalue)
  (persist-load symbol))

(defmacro persist-defvar (symbol initvalue docstring &optional location)
  "Define SYMBOL as a persistent variable and return SYMBOL.

This form is nearly equivalent to `defvar', except that the
variable persists between Emacs sessions.  When this form is
evaluated, the variable's default value is always set to
INITVALUE.

It does not support the optional parameters.  Both INITVALUE and
DOCSTRING need to be given."
  ;; We cannot distinguish between calls with initvalue of nil and a
  ;; single parameter call. Unfortunately, these two calls have
  ;; different semantics -- the single arity shuts up the byte
  ;; compiler, but does not define the symbol. So, don't support a
  ;; single arity persist-defvar.

  ;; Don't support 2-arity calls either because we are lazy and
  ;; because if you want to persist it, you want to doc it.
  (declare (debug (symbolp form stringp &optional form))
           (doc-string 3)
           (indent defun))
  ;; Define inside progn so the byte compiler sees defvar
  `(progn
     (defvar ,symbol ,initvalue ,docstring)
     ;; `defvar' must stay at top level within `progn'.  Pass init
     ;; value to `persist--defvar-1' since the `defvar' form may not
     ;; set the symbol's value and we don't want to set the
     ;; persist-default property to the current value of the symbol.
     ;; See bug#75779 for details.
     (persist--defvar-1 ',symbol ,location ,initvalue)
     ',symbol))

(defun persist-location (symbol directory)
  "Set the directory for persisting the value of symbol.

This does not force the loading of value from this directory, so
to persist a variable, you will normally need to call
`persist-load' to load a previously saved location."
  (put symbol 'persist-location (expand-file-name directory)))

(defun persist-symbol (symbol initvalue)
  "Make SYMBOL a persistent variable.

INITVALUE is the value to which SYMBOL will be set if `persist-reset' is
called.  INITVALUE is set for the session and will itself not persist
across sessions.

This does force the loading of value from this directory, so to
persist a variable, you will normally need to call `persist-load'
to load a previously saved location."
  (add-to-list 'persist--symbols symbol)
  (put symbol 'persist t)
  (put symbol 'persist-default (persist-copy initvalue)))

(defun persist--persistant-p (symbol)
  "Return non-nil if SYMBOL is a persistent variable."
  (get symbol 'persist))

(defun persist-save (symbol)
  "Save SYMBOL now.

Normally, it should not be necessary to call this explicitly, as
variables persist automatically when Emacs exits."
  (unless (persist--persistant-p symbol)
    (error (format
            "Symbol %s is not persistent" symbol)))
  (let ((symbol-file-loc (persist--file-location symbol)))
    (if (persist-equal (symbol-value symbol)
                       (persist-default symbol))
        (when (file-exists-p symbol-file-loc)
          (delete-file symbol-file-loc))
      (let ((dir-loc
             (file-name-directory symbol-file-loc)))
        (unless (file-exists-p dir-loc)
          (mkdir dir-loc))
        (with-temp-buffer
          (let (print-level
                print-length
                print-quoted
                (print-escape-control-characters t)
                (print-escape-nonascii t)
                (print-circle t))
            (print (symbol-value symbol) (current-buffer)))
          (write-region (point-min) (point-max)
                        symbol-file-loc
                        nil 'quiet))))))

(defun persist-default (symbol)
  "Return the default value for SYMBOL."
  (get symbol 'persist-default))

(defun persist-reset (symbol)
  "Set the value of SYMBOL to a copy of the default."
  (set symbol (persist-copy (persist-default symbol))))

(defun persist-load (symbol)
  "Load the saved value of SYMBOL."
  (when (file-exists-p (persist--file-location symbol))
    (with-temp-buffer
      (insert-file-contents (persist--file-location symbol))
      (let ((val (read (current-buffer))))
        (when (run-hook-with-args-until-failure 'persist-load-hook
                                                symbol val)
          (set symbol val))))))

(defun persist-unpersist (symbol)
  "Stop the value in SYMBOL from persisting.

This does not remove any saved value of SYMBOL."
  (put symbol 'persist nil)
  (setq persist--symbols
        (remove symbol persist--symbols)))

(defun persist--save-all ()
  "Save all persistent symbols."
  (mapc 'persist-save persist--symbols))

;; Save on kill-emacs-hook anyway
(add-hook 'kill-emacs-hook
          'persist--save-all)

(defun persist-equal (a b)
  "Return non-nil when the values of A and B are equal.
A and B are compared using `equal' unless they are both hash
tables.  In that case, the following are compared:

- hash table count
- hash table predicate
- values, using `persist-equal'"
  (if (and (hash-table-p a) (hash-table-p b))
      (and (= (hash-table-count a) (hash-table-count b))
           (eq (hash-table-test a) (hash-table-test b))
           (catch 'done
             (maphash
              (lambda (key a-value)
                (unless (persist-equal a-value (gethash key b (not a-value)))
                  (throw 'done nil)))
              a)
             t))
    (equal a b)))

(defun persist-copy (obj)
  "Return copy of OBJ."
  (if (hash-table-p obj)
      (copy-hash-table obj)
    (compat-call copy-tree obj t)))

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