Emacs script pitfalls
Writing scripts with Emacs Lisp is tricky.
Shebang line
Emacs has a --script
option to load and eval a file:
#!/usr/bin/emacs --script
(message "Hello world")
But we can’t assume the location of the emacs
binary… What if we use /usr/bin/env
?
#!/usr/bin/env emacs --script
(message "Hello world")
Linux doesn’t split arguments in the shebang, and sends emacs --script
as a single argument to /usr/bin/env
.
The portable and reliable way is this evil trick:
#!/bin/sh
":"; exec emacs --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello world")
Inhibiting site-start
The --script
option is a shortcut for --batch --load
, i.e. enter batch mode and load the given file. While --batch
disables the user initialization file, it still processes the global site initialization file, which we don’t want. We can add --quick
though, which is similar to --no-init-file --no-site-file --no-splash
:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello world")
Processing command-line arguments
We can access command-line arguments via command-line-args-left
, or its alias argv
:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello: %S" argv)
Passing options causes a weird error:
./hello.el --greeting 'Good morning %s!' 'John Doe'
# Hello: ("--greeting" "Good morning %s!" "John Doe")
# Unknown option `--greeting'
This is because Emacs processes all arguments in order of their appearance.
One thing we can do is to empty argv
at the end of our script:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello: %S" argv)
(setq argv nil)
Another thing we can do is to force Emacs to exit early:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello: %S" argv)
(kill-emacs 0)
Either way, Emacs now ignores our custom options:
./hello.el --greeting 'Good morning %s!' 'John Doe'
# Hello: ("--greeting" "Good morning %s!" "John Doe")
However, it still tries to process the ones it understands:
./hello.el --version 'John Doe'
# GNU Emacs 29.1
# Copyright (C) 2023 Free Software Foundation, Inc.
# GNU Emacs comes with ABSOLUTELY NO WARRANTY.
# You may redistribute copies of GNU Emacs
# under the terms of the GNU General Public License.
# For more information about these matters, see the file named COPYING.
Emacs printed its own version and exited before our script even saw the --version
argument.
We can solve the issue by separating Emacs arguments from our own with --
:
#!/bin/sh
":"; exec emacs --quick --script "$0" -- "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello: %S" argv)
(kill-emacs 0)
We’ll get the separator in the list of arguments though:
./hello.el --version 'John Doe'
#Hello: ("--" "--version" "John Doe")
If we’re processing arguments in a loop, we need to remember to pop the first one:
#!/bin/sh
":"; exec emacs --quick --script "$0" -- "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(let ((greeting "Hello %s!")
options-done
names)
(pop argv) ; Remove the "--" separator
(while argv
(let ((option (pop argv)))
(cond
(options-done (push option names))
;; Don't process options after "--"
((string= option "--") (setq options-done t))
((string= option "--greeting")
(setq greeting (pop argv)))
((string-match "\\`--greeting=\\(\\(?:.\\|\n\\)*\\)\\'" option)
(setq greeting (match-string 1 option)))
((string-prefix-p "--" option)
(message "Unknown option: %s" option)
(kill-emacs 1))
(t (push option names)))
(unless (> (length greeting) 0)
(message "Missing argument for `--greeting`!")
(kill-emacs 1))))
(unless names
(message "Missing names!")
(kill-emacs 1))
(dolist (name (nreverse names))
(message greeting name))
(kill-emacs 0))
Now Emacs doesn’t interfere with our options and arguments:
./hello.el --greeting='Hello %s' 'John Doe' 'Donald Duck'
# Hello John Doe
# Hello Donald Duck
Standard output
The message
function writes to stderr
:
./hello.el 'John Doe' 'Donald Duck' > /dev/null
# Hello John Doe!
# Hello Donald Duck!
However, these were not errors, so we should probably be writing them to stdout
:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-
(while argv
(princ (format "Hello %s!" (pop argv)))
(terpri))
(kill-emacs 0)
Standard input
The minibuffer reads from stdin
in batch mode:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-
(let (name)
(while (and (setq name (ignore-errors (read-from-minibuffer "")))
(> (length name) 0))
(princ (format "Hello %s!" name))
(terpri)))
(kill-emacs 0)
Debugging
We can enable stacktraces at the start of the program, by setting debug-on-error
:
#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-
(setq debug-on-error t)
(message "%S" (+ (car argv) (cadr argv)))
(setq argv nil)