Subject: bug#57955: 29.0.50; Allow session-local ERC modules

From: "J.P." <[email-masked]>
Date: Sun, 09 Feb 2025 12:46:14 -0800

I'd like to reexamine the scope of this bug because it's a partial
blocker for bug#49860 (IRCv3). You'll recall among its original goals
were two overlapping concerns:

  a. Granular configuration of a local module's user options
  b. Persistence of a local module's data across reconnections

One idea bandied about for addressing the first was to recommend and
accommodate buffer-local options, that is, recommend that options in a
local module's Custom group be explicitly declared buffer-local with the
:local `defcustom' keyword and that they be given local bindings on
module activation, thus initializing them with values from the current
environment. While this is technically feasible, a few notable
complications would need sorting out [1].

A complementary aspect addressing the second goal of convenient
persistence was also previously floated and amounted to leveraging the
`permanent-local' symbol property on a local module's own variables of
interest to sustain them across reconnections. With local user options,
this would likely involve the `permanent-only' argument to the :local
`defcustom' keyword explained in (info "(elisp) Variable Definitions").
For the stated purpose of sustaining variables of interest across
reconnection boundaries, this approach remains viable [2], at least for
third party modules that don't know about the internal persistence
mechanism [3].

So, in light of the new proposal for "scoped" configuration now
officially on the table [4], it might behoove us to just pretend the
granularity objective is henceforth solely the domain of that proposal's
bug (bug#76019). That'll allow us, here, in this bug, to focus entirely
on the second objective about persistence and to hopefully arrive at
something worthy of some finality for 5.7. To that end, here's some
related territory possibly worth exploring:

  1. A public utility function to access the prior buffer's local
     variables during reconnection

  2. A managed facility for declaring arbitrary persisted data with
     supporting CRUD operations
  
  3. Optional helpers for an option's :set function that update
     persisted values in affected buffers or inform users to cycle the
     mode or restart the session

  4. Documenting differences in how a local module's mode command
     variants behave with the various flavors of local modules, like
     session-wide, target-only, etc.

  5. An advanced tutorial on how to write a local module using only the
     public API via a fully functional demo

To get started, I've attached a PoC of a possible approach for point 2
(the CRUD thing). It turns out my having explored the idea some has led
me to the opinion that it's probably better to stick to points 4 and 5
only and to let module authors deal with the rest. Basically, I'm not
sure asking anyone to adopt yet another magical abstraction layer just
to persist state is any less mentally taxing than asking them to wrangle
it all themselves using lower level Emacs facilities, so long as we
provide clear guidelines and examples with any necessary boilerplate. Of
course, this observation disregards maintainability concerns, so we'd
need to be pretty certain all related infrastructure is mostly here to
stay (famous last words). More to come on this shortly.

Thanks.

[1] Possible complications with the :local `defcustom' keyword idea:

    . Most users are unfamiliar with buffer-local options. And, AFAICT,
      Customize doesn't itself prescribe how such an option's variable
      should be made buffer local nor how or whether relevant updates
      ought to be propagated across existing local bindings when the
      default value is updated. It would seem such concerns are the
      responsibility of the application. But these are *user* options,
      and users can't be bothered to learn the idiosyncrasies of each
      app just to configure it to behave as expected. They'll either
      move on or risk contending with unwelcome surprises.

    . The buffer-wise "scope" doesn't always align perfectly with
      contexts endemic to IRC, the most important being the
      connection-wise session, which spans multiple buffers (internally,
      those having the same `erc-networks--id'). Users on 29+ might be
      able to use `setopt' to update the value cleanly within a session,
      for example, in a server buffer, and have the change shared with
      all targets as well. But, users on 27 and 28 can't be expected to
      invoke the option's :set function outside of Custom buffers,
      although advanced users can manually apply updates via the
      module's explicit enable/disable command variants.

    . Modules oftentimes ignore the value of an option after
      initialization and instead use something derived from the original
      value and then progressively refined. Local modules also perform
      other initialization tasks based on the value of an option, such
      as subscribe to certain hooks. While buffer-local options may
      agree sufficiently with this pattern, so long as they're bound
      before module setup code runs in a new or reused buffer, the
      pattern dictates that a module capture a "snapshot" of an option's
      value anyway, so there's no reason to prefer buffer-local bindings
      over, say, more ephemeral and arguably easier to reason about
      `let' bindings.

    . Per-target options won't magically work when local in a target
      buffer because ERC often decides on target-related business with
      the server buffer current. Indeed, the target buffer in question
      may not even exist yet, which happens most often in response
      handlers, such as `erc-server-PRIVMSG'. Although this situation
      can be remedied, doing it in a backward compatible way seems a
      chore.

[2] A local module's mode variable itself can't be `permanent-local'
    because the majority of setup it performs won't survive a major-mode
    reset, thus creating an "inconsistent state" during the crucial
    reinitialization period when modules inspect and even modify one
    another. It's then that they also need to possibly recall the
    original value of variables not owned by them or even ERC (and these
    definitely can't be made `permanent-local').

[3] The internal inter-session persistence mechanism consists mainly of
    a crude restoration ritual for transferring values from old buffers
    to new via the variables `erc--server-reconnecting' and
    `erc--target-priors'. These are bound at module initialization time
    to an alist containing the local variables of the "reassociated"
    buffer, if any. Aside from those symbol names not being great and
    there being no public interface, there's also no way to recover if
    something goes awry during (re)initialization: restarting the
    session from scratch won't work so long as module-managed local
    variables are still bound to unusable values in the old buffer.
    Basically, the offending local module must run its "disable body"
    somewhere: either in the old buffer, before reassociating, or in the
    new one upon failure. Clearly, a friendlier and ideally more robust
    user-facing solution is necessary.

[4] https://debbugs.gnu.org/cgi/bugreport.cgi?bug=76019

>From d0c47c1dddbff1bf51ff2fb62149baa6be45fc8e Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Mon, 3 Feb 2025 23:05:24 -0800
Subject: [PATCH 1/2] [5.7] Skip already enabled local modules in erc-open

* lisp/erc/erc.el (erc-open): When activating local modules, skip those
that have just been enabled by a fellow module.  Do this even though
their setup code is meant to be idempotent.  (Bug#57955)
---
 lisp/erc/erc.el | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index afa8e0a7b72..0d72b46360e 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2662,7 +2662,9 @@ erc-open
     (erc--initialize-markers old-point continued-session)
     (erc-determine-parameters server port nick full-name user passwd)
     (save-excursion (run-mode-hooks)
-                    (dolist (mod (car delayed-modules)) (funcall mod +1))
+                    (dolist (mod (car delayed-modules))
+                      (unless (and (boundp mod) (symbol-value mod))
+                        (funcall mod +1)))
                     (dolist (var (cdr delayed-modules)) (set var nil)))
 
     ;; Saving log file on exit
-- 
2.48.1

>From 1a1f02e9dd21ec74bb489dedd6252a8f5e1b367d Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Sat, 3 Jun 2023 09:00:00 -0700
Subject: [PATCH 2/2] [5.7] Provide API for persisting local-module state in
 ERC

* lisp/erc/erc-common.el (erc--locals-get, erc--locals-define)
(erc--locals-persist): New function.
* lisp/erc/erc-sasl.el (erc-sasl--options): Remove unused variable.
(erc--sasl-locals): New variable and struct of the same name.
(erc-sasl--get-user): Use new API.
(erc-sasl-mode, erc-sasl-enable): Relocate above references that depend
on the variable `erc--sasl-locals' being defined.
(erc-sasl--read-password, erc-sasl--create-client)
(erc-sasl--mechanism-offered-p, erc-server-908)
(erc--register-connection): Use new API.
* lisp/erc/erc.el (erc-open): Don't activate local modules if they're
already enabled, even though their setup code is meant to be idempotent.
* test/lisp/erc/erc-sasl-tests.el (erc-sasl--mechanism-offered-p)
(erc-sasl--read-password--basic, erc-sasl--read-password--auth-source)
(erc-sasl-create-client--plain, erc-sasl-create-client--external)
(erc-sasl-create-client--scram-sha-1)
(erc-sasl-create-client--scram-sha-256)
(erc-sasl-create-client--scram-sha-256--no-authzid)
(erc-sasl-create-client--scram-sha-512--no-authzid): Replace references
to `erc-sasl--options' with `erc--sasl-locals'.
* test/lisp/erc/erc-tests.el (erc-tests-foo-mode, erc-tests-foo-enable)
(erc-tests-foo-disable, erc--tests-foo-locals): New variables,
functions, and struct.
(erc--locals-define/baseline, erc--locals-define/mutate): New
tests.  (Bug#57955)
---
 lisp/erc/erc-common.el          | 40 ++++++++++++++
 lisp/erc/erc-sasl.el            | 93 +++++++++++++++++----------------
 test/lisp/erc/erc-sasl-tests.el | 47 +++++++++--------
 test/lisp/erc/erc-tests.el      | 55 +++++++++++++++++++
 4 files changed, 167 insertions(+), 68 deletions(-)

diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index d293e6ba878..5e0a53bcfab 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -211,6 +211,46 @@ erc--favor-changed-reverted-modules-state
                erc-modules)
     (customize-mark-as-set 'erc-modules)))
 
+(defmacro erc--locals-get (module key)
+  "Return MODULE's persistent buffer-local value for KEY.
+Serve as a generalized place variable."
+  (let* ((var (intern (format "erc--%s-locals" module)))
+         (accessor (intern-soft (format "%s-%s" var key))))
+    `(,accessor ,var)))
+
+(defmacro erc--locals-define (module &rest slots)
+  "Define a buffer-local data type for local MODULE.
+Ensure it survives (re)initialization of MODULE for the duration of an
+Emacs session but is still destroyed if disabling the module.  Expect
+SLOTS to be of the kind used by `cl-defstruct' and MODULE to be a
+\"normalized\" module symbol.  Also define a permanent-local variable
+named `erc--MODULE-locals' to hold the data structure and a function to
+kill that variable when disabling MODULE."
+  (declare (indent 1))
+  (let* ((type (intern (format "erc--%s-locals" module)))
+         (kill (intern (format "%s-kill" type)))
+         (mode (intern (format "erc-%s-mode" module)))
+         (hook (intern (format "erc-%s-mode-hook" module))))
+    `(progn
+       (defvar-local ,type nil
+         ,(format "Persistent data for `%s'." mode))
+       (put ',type 'permanent-local t)
+       (defun ,kill ()
+         (unless (bound-and-true-p ,mode)
+           (kill-local-variable ',type)))
+       (add-hook ',hook #',kill 50) ; global
+       (cl-defstruct (,type (:constructor ,type))
+         ,(format "Persistent data for local module %s." module)
+         ,@slots))))
+
+(defmacro erc--locals-persist (module &rest plist)
+  "If needed, initialize MODULE's data in current buffer from PLIST.
+Expect to run in the \"enable body\" of MODULE's minor mode command."
+  (declare (indent 1))
+  (let ((var (intern (format "erc--%s-locals" module))))
+    `(unless ,var
+       (setq ,var (funcall #',var ,@plist)))))
+
 (defun erc--assemble-toggle (localp name ablsym mode val body)
   (let ((arg (make-symbol "arg")))
     `(defun ,ablsym ,(if localp `(&optional ,arg) '())
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index a16f554f2d1..1e746be8a62 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -112,9 +112,6 @@ erc-sasl-authzid
   :type '(choice (const nil) string))

-;; Analogous to what erc-backend does to persist opening params.
-(defvar-local erc-sasl--options nil)
-
 ;; Session-local (server buffer) SASL subproto state
 (defvar-local erc-sasl--state nil)
 
@@ -124,12 +121,40 @@ erc-sasl--state
   (step nil :type vector)
   (pending nil :type string))
 
+(erc--locals-define sasl
+  (user erc-sasl-user)
+  (password erc-sasl-password)
+  (mechanism erc-sasl-mechanism)
+  ;; FIXME use truncated option names for these slots like above.
+  (authfn erc-sasl-auth-source-function)
+  (authzid erc-sasl-authzid))
+
 (defun erc-sasl--get-user ()
-  (pcase (alist-get 'user erc-sasl--options)
+  (pcase (erc--locals-get sasl user)
     (:user erc-session-username)
     (:nick (erc-current-nick))
     (v v)))
 
+(define-erc-module sasl nil
+  "Non-IRCv3 SASL support for ERC.
+This doesn't solicit or validate a suite of supported mechanisms."
+  ;; See bug#49860 for a CAP 3.2-aware WIP implementation.
+  ((unless erc--target
+     (setq erc-sasl--state (make-erc-sasl--state))
+     ;; If the previous attempt failed during registration, this may be
+     ;; non-nil and contain erroneous values, but how can we detect that?
+     ;; What if the server dropped the connection for some other reason?
+     (erc--locals-persist sasl)
+     (let* ((mech (erc--locals-get sasl mechanism))
+            (client (erc-sasl--create-client mech)))
+       (unless client
+         (erc-display-error-notice
+          nil (format "Unknown or unsupported SASL mechanism: `%s'" mech))
+         (error "Unknown or unsupported SASL mechanism: `%s'" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((kill-local-variable 'erc-sasl--state))
+  localp)
+
 (defun erc-sasl-auth-source-password-as-host (&rest plist)
   "Call `erc-auth-source-search' with `erc-sasl-password' as `:host'.
 But only do so when it's a string or a non-nil symbol, unless
@@ -148,15 +173,17 @@ erc-sasl-auth-source-password-as-host
 (defun erc-sasl--read-password (prompt)
   "Return configured option or server password.
 If necessary, pass PROMPT to `read-passwd'."
-  (if-let* ((found (pcase (alist-get 'password erc-sasl--options)
-                     ((guard (alist-get 'authfn erc-sasl--options))
-                      (let-alist erc-sasl--options
-                        (let ((erc-sasl-user .user)
-                              (erc-sasl-password .password)
-                              (erc-sasl-mechanism .mechanism)
-                              (erc-sasl-authzid .authzid)
-                              (erc-sasl-auth-source-function .authfn))
-                          (funcall .authfn :user (erc-sasl--get-user)))))
+  (if-let* ((found (pcase (erc--locals-get sasl password)
+                     ((guard (erc--locals-get sasl authfn))
+                      (pcase erc--sasl-locals
+                        ((cl-struct erc--sasl-locals
+                                    (user erc-sasl-user)
+                                    (password erc-sasl-password)
+                                    (mechanism erc-sasl-mechanism)
+                                    (authzid erc-sasl-authzid)
+                                    (authfn erc-sasl-auth-source-function))
+                         (funcall erc-sasl-auth-source-function
+                                  :user (erc-sasl--get-user)))))
                      (:password erc-session-password)
                      ((and (pred stringp) v) (unless (string-empty-p v) v)))))
       (copy-sequence (erc--unfun found))
@@ -250,7 +277,7 @@ erc-sasl--create-client
                                      (erc-sasl--get-user)
                                      "N/A" "N/A"))
       (sasl-client-set-property client 'authenticator-name
-                                (alist-get 'authzid erc-sasl--options))
+                                (erc--locals-get sasl authzid))
       client)))
 
 (cl-defmethod erc-sasl--create-client ((_ (eql plain)))
@@ -268,7 +295,7 @@ erc-sasl--create-client
          (mech (sasl-find-mechanism '("PLAIN")))
          (client (sasl-make-client mech authc port host)))
     (sasl-client-set-property client 'authenticator-name
-                              (alist-get 'authzid erc-sasl--options))
+                              (erc--locals-get sasl authzid))
     client))
 
 (cl-defmethod erc-sasl--create-client ((_ (eql scram-sha-256)))
@@ -283,7 +310,7 @@ erc-sasl--create-client
 
 (cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
   "Create and return a new ECDSA-NIST256P-CHALLENGE client."
-  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+  (let ((keyfile (erc--locals-get sasl password)))
     ;; Better to signal usage errors now than inside a process filter.
     (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
            (erc-display-error-notice
@@ -301,7 +328,7 @@ erc-sasl--mechanism-offered-p
   "Return non-nil when OFFERED appears among a list of mechanisms."
   (string-match-p (rx-to-string
                    `(: (| bot ",")
-                       ,(symbol-name (alist-get 'mechanism erc-sasl--options))
+                       ,(symbol-name (erc--locals-get sasl mechanism))
                        (| eot ",")))
                   (downcase offered)))
 
@@ -313,32 +340,6 @@ english
    (s907 . "ERR_SASLALREADY (already authenticated) %s")
    (s908 . "RPL_SASLMECHS (unsupported mechanism: %m) %s")))
 
-(define-erc-module sasl nil
-  "Non-IRCv3 SASL support for ERC.
-This doesn't solicit or validate a suite of supported mechanisms."
-  ;; See bug#49860 for a CAP 3.2-aware WIP implementation.
-  ((unless erc--target
-     (setq erc-sasl--state (make-erc-sasl--state))
-     ;; If the previous attempt failed during registration, this may be
-     ;; non-nil and contain erroneous values, but how can we detect that?
-     ;; What if the server dropped the connection for some other reason?
-     (erc--restore-initialize-priors erc-sasl-mode
-       erc-sasl--options `((user . ,erc-sasl-user)
-                           (password . ,erc-sasl-password)
-                           (mechanism . ,erc-sasl-mechanism)
-                           (authfn . ,erc-sasl-auth-source-function)
-                           (authzid . ,erc-sasl-authzid)))
-     (let* ((mech (alist-get 'mechanism erc-sasl--options))
-            (client (erc-sasl--create-client mech)))
-       (unless client
-         (erc-display-error-notice
-          nil (format "Unknown or unsupported SASL mechanism: `%s'" mech))
-         (error "Unknown or unsupported SASL mechanism: `%s'" mech))
-       (setf (erc-sasl--state-client erc-sasl--state) client))))
-  ((kill-local-variable 'erc-sasl--state)
-   (kill-local-variable 'erc-sasl--options))
-  localp)
-
 (define-erc-response-handler (AUTHENTICATE)
   "Begin or resume an SASL session." nil
   (if-let* ((response (car (erc-response.command-args parsed)))
@@ -405,7 +406,7 @@ erc-sasl--destroy
 (define-erc-response-handler (908)
   "Handle a RPL_SASLMECHS response." nil
   (erc-display-message parsed '(notice error) 'active 's908
-                       ?m (alist-get 'mechanism erc-sasl--options)
+                       ?m (erc--locals-get sasl mechanism)
                        ?s (string-join (cdr (erc-response.command-args parsed))
                                        " "))
   (erc-sasl--destroy proc))
@@ -425,11 +426,11 @@ erc--register-connection
         (let ((erc-session-password
                (and erc-session-password
                     (not (eq :password
-                             (alist-get 'password erc-sasl--options)))
+                             (erc--locals-get sasl password)))
                     erc-session-password))
               (erc-session-username
                ;; The username may contain a colon or a space
-               (if (eq :user (alist-get 'user erc-sasl--options))
+               (if (eq :user (erc--locals-get sasl user))
                    (erc-current-nick)
                  erc-session-username)))
           (cl-call-next-method))
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 29fc7bef033..e9c32f6d3aa 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -25,7 +25,7 @@
 (require 'erc-sasl)
 
 (ert-deftest erc-sasl--mechanism-offered-p ()
-  (let ((erc-sasl--options '((mechanism . external))))
+  (let ((erc--sasl-locals (erc--sasl-locals :mechanism 'external)))
     (should (erc-sasl--mechanism-offered-p "foo,external"))
     (should (erc-sasl--mechanism-offered-p "external,bar"))
     (should (erc-sasl--mechanism-offered-p "foo,external,bar"))
@@ -34,25 +34,25 @@ erc-sasl--mechanism-offered-p
 
 (ert-deftest erc-sasl--read-password--basic ()
   (ert-info ("Explicit erc-sasl-password")
-    (let ((erc-sasl--options '((password . "foo"))))
+    (let ((erc--sasl-locals (erc--sasl-locals :password "foo")))
       (should (string= (erc-sasl--read-password nil) "foo"))))
 
   (ert-info ("Explicit session password")
     (let ((erc-session-password "foo")
-          (erc-sasl--options '((password . :password))))
+          (erc--sasl-locals (erc--sasl-locals :password :password)))
       (should (string= (erc-sasl--read-password nil) "foo"))))
 
   (ert-info ("Prompt when no authfn and :password resolves to nil")
     (let ((erc-session-password nil)
-          (erc-sasl--options
-           '((password . :password) (user . :user) (authfn))))
+          (erc--sasl-locals (erc--sasl-locals :password :password
+                                              :user :user)))
       (should (string= (ert-simulate-keys "bar\r"
                          (erc-sasl--read-password "?"))
                        "bar"))))
 
   (ert-info ("Prompt when auth-source fails and `erc-session-password' null")
     (should-not erc-session-password)
-    (let ((erc-sasl--options '((password) (authfn . ignore))))
+    (let ((erc--sasl-locals (erc--sasl-locals :authfn #'ignore)))
       (should (string= (ert-simulate-keys "baz\r"
                          (erc-sasl--read-password "pwd:"))
                        "baz")))))
@@ -83,36 +83,36 @@ erc-sasl--read-password--auth-source
                   '((name . erc-sasl--read-password--auth-source)))
 
       (ert-info ("Symbol as password specifies machine")
-        (let ((erc-sasl--options
-               `((user . "bob") (password . FSF.chat) (authfn . ,fn))))
+        (let ((erc--sasl-locals
+               (erc--sasl-locals :user "bob" :password 'FSF.chat :authfn fn)))
           (should (string= (erc-sasl--read-password nil) "sesame"))
           (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
       (ert-info (":password as password resolved to machine")
         (let ((erc-session-password "FSF.chat")
-              (erc-sasl--options
-               `((user . "bob") (password . :password) (authfn . ,fn))))
+              (erc--sasl-locals
+               (erc--sasl-locals :user "bob" :password :password :authfn fn)))
           (should (string= (erc-sasl--read-password nil) "sesame"))
           (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
       (ert-info (":user resolved to `erc-session-username'") ; *1
         (let ((erc-session-username "bob")
-              (erc-sasl--options `((user . :user) (password) (authfn . ,fn)))
+              (erc--sasl-locals (erc--sasl-locals :user :user :authfn fn))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
           (should (equal (pop calls) '(:user "bob")))))
 
       (ert-info (":user resolved to current nick") ; *1
         (let ((erc-server-current-nick "bob")
-              (erc-sasl--options `((user . :nick) (password) (authfn . ,fn)))
+              (erc--sasl-locals (erc--sasl-locals :user :nick :authfn fn))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
           (should (equal (pop calls) '(:user "bob")))))
 
       (ert-info ("Symbol as password, entry lacks user field")
         (let ((erc-server-current-nick "fake")
-              (erc-sasl--options
-               `((user . :nick) (password . MyHost) (authfn . ,fn)))
+              (erc--sasl-locals
+               (erc--sasl-locals :user :nick :password 'MyHost :authfn fn))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "123"))
           (should (equal (pop calls) '(:user "fake" :host "MyHost")))))
@@ -123,7 +123,7 @@ erc-sasl--read-password--auth-source
 (ert-deftest erc-sasl-create-client--plain ()
   (let* ((erc-session-password "password123")
          (erc-session-username "tester")
-         (erc-sasl--options '((user . :user) (password . :password)))
+         (erc--sasl-locals (erc--sasl-locals :user :user :password :password))
          (erc-session-port 1667)
          (erc-session-server "localhost")
          (client (erc-sasl--create-client 'plain))
@@ -137,7 +137,7 @@ erc-sasl-create-client--plain
 
 (ert-deftest erc-sasl-create-client--external ()
   (let* ((erc-server-current-nick "tester")
-         (erc-sasl--options '((user . :nick) (password . :password)))
+         (erc--sasl-locals (erc--sasl-locals :user :nick :password :password))
          (client (erc-sasl--create-client 'external)) ; unused ^
          (result (sasl-next-step client nil)))
     (should (equal (format "%S" [ignore nil]) (format "%S" result)))
@@ -147,8 +147,9 @@ erc-sasl-create-client--external
   (should-not (assoc-default "EXTERNAL" sasl-mechanism-alist)))
 
 (ert-deftest erc-sasl-create-client--scram-sha-1 ()
-  (let* ((erc-sasl--options '((user . "jilles") (password . "sesame")
-                              (authzid . "jilles")))
+  (let* ((erc--sasl-locals (erc--sasl-locals :user "jilles"
+                                             :password "sesame"
+                                             :authzid "jilles"))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-1))
@@ -186,8 +187,9 @@ erc-sasl-create-client--scram-sha-256
     (ert-skip "Emacs lacks sasl-scram-sha256"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
-         (erc-sasl--options '((user . :nick) (password . :password)
-                              (authzid . "jilles")))
+         (erc--sasl-locals (erc--sasl-locals :user :nick
+                                             :password :password
+                                             :authzid "jilles"))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-256))
@@ -227,7 +229,7 @@ erc-sasl-create-client--scram-sha-256--no-authzid
     (ert-skip "Emacs lacks sasl-scram-sha256"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
-         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (erc--sasl-locals (erc--sasl-locals :user :nick))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-256))
@@ -267,7 +269,8 @@ erc-sasl-create-client--scram-sha-512--no-authzid
     (ert-skip "Emacs lacks sasl-scram-sha512"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
-         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (erc--sasl-locals (erc--sasl-locals :user :nick
+                                             :password :password))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-512))
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index df9e4d52f77..7be9902f527 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -3792,6 +3792,61 @@ erc--merge-local-modes
         (should (equal (erc--merge-local-modes new old)
                        '((erc-d-mode) . (erc-b-mode))))))))
 
+(erc--locals-define tests-foo
+  (a (error "Argument A required"))
+  (b 1 :read-only t)
+  (c (and erc--target 2)))
+
+(define-erc-module tests-foo nil "Use local persistent data."
+  ((erc--locals-persist tests-foo :a 0))
+  ((ignore))
+  localp)
+
+(ert-deftest erc--locals-define/baseline ()
+  (erc-mode)
+  (should-not erc--tests-foo-locals)
+  (erc-tests-foo-mode +1)
+  (should erc--tests-foo-locals)
+  (should (= (erc--tests-foo-locals-a erc--tests-foo-locals) 0))
+  (should (= (erc--tests-foo-locals-b erc--tests-foo-locals) 1))
+  (should-not (erc--tests-foo-locals-c erc--tests-foo-locals))
+
+  ;; Local var `erc--tests-foo-locals' survives a major-mode reset.
+  ;; However, enabling the minor mode does not re-initialize the
+  ;; values: they're preserved from the previous session.
+  (erc-mode)
+  (setq erc--target (erc--target-from-string "Bob"))
+  (should erc--tests-foo-locals)
+  (erc-tests-foo-mode +1)
+  (should (= (erc--locals-get tests-foo a) 0))
+  (should (= (erc--locals-get tests-foo b) 1))
+  (should (null (erc--locals-get tests-foo c)))
+
+  ;; Cycling the mode *does* reinitialize the data.
+  (erc-tests-foo-mode -1)
+  (should-not erc--tests-foo-locals)
+  (erc-tests-foo-mode +1)
+  (should (equal (erc--locals-get tests-foo c) 2)))
+
+(ert-deftest erc--locals-define/mutate ()
+  (erc-mode)
+  (should-not erc--tests-foo-locals)
+  (erc-tests-foo-mode +1)
+  (should erc--tests-foo-locals)
+  (should (= (erc--locals-get tests-foo a) 0))
+  (should (= (erc--locals-get tests-foo b) 1))
+  (should-not (erc--locals-get tests-foo c))
+
+  ;; Writing to a read-only slot signals an error.
+  (should-error (setf (erc--locals-get tests-foo b) 42))
+
+  ;; Local macro works as a generalized variable.
+  (push '(y . Y) (erc--locals-get tests-foo c))
+  (push '(x . X) (erc--locals-get tests-foo c))
+  (should (equal (erc--locals-get tests-foo c) '((x . X) (y . Y))))
+  (setf (alist-get 'y (erc--locals-get tests-foo c)) 'YY)
+  (should (equal (erc--locals-get tests-foo c) '((x . X) (y . YY)))))
+
 (ert-deftest define-erc-module--global ()
   (let ((global-module '(define-erc-module mname malias
                           "Some docstring."
-- 
2.48.1