Modifystamps

Well, timestamps were marginally useful, and writestamps were somewhat more so, but modifystamps may be even better. A modifystamp is a writestamp that records the time the file was last modified, which may not be the same as the last time it was saved to disk. For instance, if you visit a file and save it under a new name without making any changes to it, you shouldn't cause the modifystamp to change.

In this section we'll briefly explore two very simple approaches to implementing modifystamps, and one clever one.

Simple Approach #1

Emacs has a hook variable called first-change-hook. Whenever a buffer is changed for the first time since it was last saved, the functions in first-change-hook get executed. Implementing modifystamps by using this hook merely entails moving our old update-writestamps function from local-write-file-hooks to first-change-hook. Of course, we'll also want to change its name to update-modifystamps, and introduce new variables—modifystamp-format, modifystamp-prefix, and modifystamp-suffix—that work like their writestamp counterparts without overloading the writestamp variables. Then update-modifystamps should be changed to use the new variables.

Before any of this happens, first-change-hook, which is normally global, should be made buffer-local. If we add update-modifystamps to first-change-hook while it is still global, update-modifystamps will be called every time any buffer is saved. Making it buffer-local in the current buffer causes changes to the variable to be invisible outside that buffer. Other buffers continue to use the default global value.

(make-local-hook 'first-change-hook)

Although ordinary variables are made buffer-local with either make-local-variable or make-variable-buffer-local (see below), hook variables must be made buffer-local with make-local-hook.

(defvar modifystamp-format "%C"
  "*Format for modifystamps (c.f. 'format-time-string').")
(defvar modifystamp-prefix "MODIFYSTAMP(("
  "*String identifying start of modifystamp.")
(defvar modifystamp-suffix "))"
  "*String that terminates a modifystamp.")
 (defun update-modifystamps ()
  "Find modifystamps and replace them with the current time."
  (save-excursion
    (save-restriction
      (save-match-data
        (widen)
        (goto-char (point-min))
        (let ((regexp (concat "^"
                              (regexp-quote modifystamp-prefix)
                              "\\(.*\\)"
                              (regexp-quote modifystamp-suffix)
                              "$")))
          (while (re-search-forward regexp nil t)
            (replace-match (format-time-string modifystamp-format
                                               (current-time))
                           t t nil 1))))))
  nil)
(add-hook 'first-change-hook 'update-modifystamps nil t)

The nil argument to add-hook is just a place holder. We care only about the last argument, t, which means "change only the buffer-local copy of first-change-hook."

The problem with this approach is that if you make ten changes to the file before saving it, the modifystamps will contain the time of the first change, not the last change. Close enough for some purposes, but we can do better.

Simple Approach #2

This time we'll go back to using local-write-file-hooks, but we'll call update-modifystamps from it only if buffer-modified-p returns true, which tells us that the current buffer has been modified since it was last saved:

(defun maybe-update-modifystamps ()
  "Call 'update-modifystamps' if the buffer has been modified."
  (if (buffer-modified-p)
      (update-modifystamps)))
(add-hook 'local-write-file-hooks 'maybe-update-modifystamps)

Now we have the opposite problem from simple approach #1: the last-modified time isn't computed until the file is saved, which may be much later than the actual time of the last modification. If you make a change to the file at 2:00 and save at 3:00, the modifystamps will record 3:00 as the last-modified time. This is a closer approximation, but it's still not perfect.

Clever Approach

Theoretically, we could call update-modifystamps after every change to the buffer, but in practice it's prohibitively expensive to scan through the whole file and rewrite parts of it after every keystroke. But it's not too expensive to memorize the current time after each buffer change. Then, when the buffer is saved to a file, the memorized time can be used for computing the time in the modifystamps.

The hook variable after-change-functions contains functions to call after each buffer change. First let's make it buffer-local:

(make-local-hook 'after-change-functions)

Now we define a buffer-local variable to hold this buffer's latest modification time:

(defvar last-change-time nil
  "Time of last buffer modification.")
(make-variable-buffer-local 'last-change-time)

The function make-variable-buffer-local causes the named variable to have a separate, buffer-local value in every buffer. This is subtly different from make-local-variable, which makes a variable have a buffer-local value in the current buffer while allowing other buffers to share the same global value. In this case, we use make-variable-buffer-local because there is no meaningful global value of last-change-time for other buffers to share.

Now we need a function to set last-change-time each time the buffer changes. Let's call it remember-change-time and add it to after-change-functions:

(add-hook 'after-change-functions 'remember-change-time nil t)

Functions in after-change-functions are passed three arguments describing the change that just took place (see the section called Mode Meat in Chapter 7). But remember-change-time doesn't care what the change was; only that there was a change. So we'll allow remember-change-time to take arguments, but we'll ignore them.

(defun remember-change-time (&rest unused)
  "Store the current time in 'last-change-time'."
  (setq last-change-time (current-time)))

The keyword &rest, followed by a parameter name, must appear last in a function's parameter list. It means "collect up any remaining arguments into a list and assign it to the last parameter" (unused in this case). The function may have other parameters, including &optional ones, but these must precede the &rest parameter. After all the other parameters are assigned in the normal fashion, the &rest parameter gets a list of whatever's left. So if a function is defined as

(defun foo (a b &rest c)
  ...)

and is called with (foo 1 2 3 4), then a will be 1, b will be 2, and c will be the list (3 4).

In some situations, &rest is very useful, even necessary; but right now we're only using it out of laziness (or economy, if you prefer), to avoid having to name three separate parameters that we don't plan to use.

Now we must revise update-modifystamps: it must use the time stored in last-change-time instead of using (current-time). For efficiency, it should also reset last-change-time to nil when it is done, so if the file is subsequently saved without being modified, we can avoid the overhead of calling update-modifystamps.

(defun update-modifystamps ()
  "Find modifystamps and replace them with the saved time."
  (save-excursion
    (save-restriction
      (save-match-data
        (widen)
        (goto-char (point-min))
        (let ((regexp (concat "^"
                              (regexp-quote modifystamp-prefix)
                              "\\(.*\\)"
                              (regexp-quote modifystamp-suffix)
                              "$")))
          (while (re-search-forward regexp nil t)
            (replace-match (format-time-string modifystamp-format
                                               last-change-time)
                           t t nil 1))))))
  (setq last-change-time nil)
  nil)

Finally, we wish not to call update-modifystamps when last-change-time is nil:

(defun maybe-update-modifystamps ()
  "Call 'update-modifystamps' if the buffer has been modified."
  (if last-change-time      ;instead of testing (buffer-modified-p)
      (update-modifystamps)))

There's still one important thing missing from maybe-update-modifystamps. Before reading ahead to the next section, can you figure out what it is?

A Subtle Bug

The problem is that every time a modifystamp gets rewritten by update-modifystamps, the buffer changes, causing last-change-time to change! Only the first modifystamp will be correctly rewritten. Subsequent ones will contain a time much closer to when the file was saved than when the last modification was made.

One way around this problem is to temporarily set the value of after-change-functions to nil while executing update-modifystamps as shown below.

(add-hook 'local-write-file-hooks
          '(lambda ()
             (if last-change-time
                 (let ((after-change-functions nil))
                   (update-modifystamps)))))

This use of let creates a temporary variable, after-change-functions, that supersedes the global after-change-functions during the call to update-modifystamps in the body of the let. After the let exits, the temporary after-change-functions disappears and the global one is again in effect.

This solution has a drawback: if there are other functions in after-change-functions, they'll also be disabled during the call to update-modifystamps, though you might not intend for them to be.

A better solution would be to "capture" the value of last-change-time before any modifystamps are updated. That way, when updating the first modifystamp causes last-change-time to change, the new value of last-change-time won't affect any remaining modifystamps because update-modifystamps won't be referring to last-change-time.

The simplest way to "capture" the value of last-change-time is to pass it as an argument to update-modifystamps:

(add-hook 'local-write-file-hooks
          '(lambda ()
             (if last-change-time
                 (update-modifystamps last-change-time))))

This requires changing update-modifystamps to take one argument and use it in the call to format-time-string:

(defun update-modifystamps (time)
  "Find modifystamps and replace them with the given time."
  (save-excursion
    (save-restriction
      (save-match-data
        (widen)
        (goto-char (point-min))
        (let ((regexp (concat "^"
                              (regexp-quote modifystamp-prefix)
                              "\\(.*\\)"
                              (regexp-quote modifystamp-suffix)
                              "$")))
          (while (re-search-forward regexp nil t)
            (replace-match (format-time-string modifystamp-format
                                               time)
                           t t nil 1))))))
  (setq last-change-time nil)
  nil)

You might be thinking that setting up a buffer to use modifystamps involves evaluating a lot of expressions and setting up a lot of variables, and that it seems hard to keep track of what's needed to make modifystamps work. If so, you're right. So in the next chapter, we'll look at how you can encapsulate a collection of related functions and variables in a Lisp file.

Get Writing GNU Emacs Extensions now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.