Changing GNU Emacs Lisp functions through advice-add, not brute force

September 22, 2023

It's a tradition with me that sooner or later, I hit a GNU Emacs function that doesn't work the way I want it to and has no applicable customization options. My traditional brute force approach to dealing with these functions has been to redefine them; I'd copy their code to my .emacs or some personal .el file, modify or replace it to taste, and then insure that my definition got used instead of the standard one. If what I really cared about was a keybinding, sometimes I could give my version a new name (and bind the key I cared about to it). Recently, however, Ben Zanin pointed me at advice-add and, after a while, I was able to work out how to do some changes with it in a nicer way than my previous brute force approach. So here are some notes.

The simplest situation for advice-add is when you want to completely replace a function with a different implementation, which is easily done with ':override':

(advice-add 'mh-forwarded-letter-subject :override
            (lambda (_ignored subject)
              (concat "Fwd: " subject)))

This redefines the function that generates the Subject: header for email forwarded in MH-E so that it completely ignores the author of the mail being forwarded (this turns out to be possible through standard customizations because format can use (only) specific arguments, unlike C printf formatting, which I didn't know at the time I hacked this up).

Sometimes I want a function to not do something, for example to never use NMH's anno to modify my existing email messages. MH-E doesn't directly expose this as a customization, but it does have a specific function to execute (N)MH commands and we can just not run that function if we're trying to execute anno:

(advice-add 'mh-exec-cmd :before-until
            (lambda (cmd &rest args)
              (string-equal cmd "anno")))

The Ways to compose advice section is a little bit confusing, but my rule of thumb is that ':before-until' is for situations where I don't want the function to run if something is true, while ':before-while' is for situations where I only want the function to run if something is true.

The brute force version of this is to use ':around', which lets you wrap around the original function in a single function of your own:

(defun no-annotate (oldfun cmd &rest args)
  (if (not (string-equal cmd "anno"))
      (apply oldfun cmd args)))
(advice-add 'mh-exec-cmd :around 'no-annotate)

Another thing you might want to do is modify the argument list of a function, for example to change how MH-E handles email replies so that you always automatically get cc'd on them. This is done with ':filter-args' advice, but this advice is slightly tricky because as far as I can see you don't get passed exactly the normal arguments; instead you just get a forced list of them. So you have to write your modification like this:

(defun repl-cc-me (args)
  (if (string-equal (car args) "repl")
      (append args '("-cc" "me"))
    args))
(advice-add 'mh-exec-cmd :filter-args 'repl-cc-me)

Although it may be an obvious thing to say, if you're going to filter the function's arguments you're going to have to know exactly what arguments it's passed, and there may be surprises. For example, mh-exec-cmd turns out not to always be passed a list of just strings; sometimes there may be numbers or sublists mixed in.

You can advise the same function (such as mh-exec-cmd) multiple times to do different things. Here I've advised it twice, one to ignore attempts to run "anno" and once to modify what "repl" gets handed as an argument. Obviously you need your advice to not clash (as is the case here); otherwise you may need to combine them in a single piece of advice that sorts it out right.

As both of these examples illustrate, you may need to advice-add some other function than your initial target. My initial targets are the behavior of 'mh-forward' and 'mh-reply', but in both cases the behavior is in the middle of the function, so unless I want to re-define them I have to hook into something else. Finding where to hook into requires reading the code and following control flow. If you need to alter one function's use of another function that is generally used (such as 'mh-exec-cmd', which is used all over MH-E), you generally need to hope that it's called with some argument you can detect that's specific to your function.

(In fact 'mh-forward's annotation behavior is a couple of layers of functions down, but once I found the root cause I decided I didn't want any annotation to be happening from anywhere, not just forwarding messages.)

One somewhat tricky option here that I didn't actually get working the one time I tried it is to create a dynamically scoped variable that you use to signal that you are in your function of interest:

(defvar mh-reply-marker nil "Are we in mh-reply?")
(defun mark-mh-reply (oldfun message &optional reply-to includep)
  (let ((mh-reply-marker t))
    (apply oldfun message reply-to includep)))
(advice-add 'mh-reply :around 'mark-mh-reply)

Then your advising code for other functions can check mh-reply-marker to see if they should do things or just quietly stay out of the way. This is a real usage case for ':around', because we need to wrap the original function in that 'let'.

The other thing that I haven't gotten fully and completely working is advising interactive functions. In theory this is supposed to work, but in practice at various times either invoking the function itself has failed or other functions that do '(call-interactive 'mh-reply)' have failed with mysterious errors. My conclusion is that this is a level of advising that is currently beyond my ken, and if I need to dabble in the waters to this depth, I'm probably back to re-defining functions (it may be brute force but it works).

Sidebar: 'let' and dynamically scoped variables

If you're new to Lisp and dynamic scoping, it may be a little bit surprising that your in-function 'let' of 'mh-reply-marker' persists even into functions that you call, but it does and this feature is actually used all over GNU Emacs in various ways. One example is temporarily forcing the value of a customizable setting to do something. MH-E can be set to prefer plain text over HTML for email with both, and it has a function 'mh-show-preferred-alternative' to override that temporarily; this function works by nulling out the customization you set and re-displaying the message. You can write an inverse of this with the same idea:

(defun mh-show-plaintext ()
  "Show text/plain instead of text/html for a message."
  (interactive)
  (let
      ((mm-discouraged-alternatives '("text/html")))
    (mh-show nil t)))

(I have other personal functions that work this way.)

Written on 22 September 2023.
« HTTP Basic Authentication and your URL hierarchy
Some questions about Unbound's domain-based rate limits (as of fall 2023) »

Page tools: View Source.
Search:
Login: Password:

Last modified: Fri Sep 22 23:04:10 2023
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.