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

From: "J.P." <[email-masked]>
Date: Tue, 18 Feb 2025 20:17:58 -0800

"J.P." <[email-masked]> writes:

> 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.

Here is an initial draft attempting to address points 4 and 5 in the
list above (patch also attached):

  File: erc.info,  Node: Module Example,  Next: Module Usage,
  Prev: Module Loading,  Up: Modules

  4.3 Example
  ===========

  This is a walk-through of a working module presented in separate chunks.
  If you'd prefer to view it as a whole, you can install it as a
  third-party package through ERC's devel archive:
  <https://emacs-erc.gitlab.io/bugs/archive/erc-view.html>.

       ;;; erc-view.el -- Automatic view-mode for ERC -*- lexical-binding: t; -*-

       ;; Maintainer: The ERC Maintainers <[email-masked]>
       ;; Keywords: convenience
       ;; Version: 0.1
       ;; Package-Requires: ((emacs "30.1"))
       ;; URL: https://gitlab.com/emacs-erc/erc-view

       ;;; Commentary:

       ;; This is a demo local module for ERC.  It arranges for automatically
       ;; enabling `view-mode' when leaving the prompt area and automatically
       ;; disabling it when reentering.  It also ensures `view-mode' stays
       ;; enabled or disabled when reconnecting.

       ;;; Code:

     You need to import ERC's main library somehow.  The easiest way is
  directly, via a simple ‘(require 'erc)’, although this module does so
  indirectly because it also uses definitions from ‘erc-goodies’:

       (require 'erc-goodies)
       (require 'view)

     Avoid headaches by aligning the name of your module with its
  containing library and Custom group.  It's best to have one group and
  one module per library.

       (defgroup erc-view nil
         "Automatically enter and exit `view-mode' in ERC."
         :version "0.1"
         :group 'erc)

       (defcustom erc-view-enable-when-exiting-prompt t
         "Whether to enable `view-mode' when exiting the prompt area."
         :type 'boolean)

       (defcustom erc-view-disable-when-entering-prompt t
         "Whether to disable `view-mode' when entering the prompt area."
         :type 'boolean)

       (defcustom erc-view-backspace-at-prompt-scrolls-down t
         "Whether a \\`<backspace>' at the prompt scrolls down to enter `view-mode'."
         :type 'boolean)

     You'll almost always want to define internal variables as
  buffer-local.

       (defvar-local erc-view--enabled-p nil
         "Current reconnect-aware activation state of `view-mode'.")

  In some cases, you may need a variable's value to survive the
  reapplication of ERC's ‘major-mode’ performed in each reassociated
  buffer upon reconnecting.  Do this by leveraging the ‘permanent-local’
  symbol property.  (*note (elisp)Creating Buffer-Local::.)

       (put 'erc-view--enabled-p 'permanent-local t)

  There are a few caveats regarding the durability of permanent values.
  By convention, disabling a module's minor mode kills local bindings.
  Mode commands, like ‘erc-view-mode’, do so in the current buffer only,
  while unidirectional ones, like ‘erc-view-mode-disable’, do so
  connection-wide.

     There are also occasions in which persistence is undefined, most
  notably when “grafting” an old buffer's contents onto a current buffer.
  This occurs in server buffers upon “logical connection” (at ‘MOTD’'s
  end), when a user reconnects with a new invocation of an entry-point
  command, like ‘erc-tls’, instead of via the auto-reconnect facility or
  by issuing a ‘/reconnect’ at the prompt.  Unaffected are entry-point
  invocations that include an ‘:id’ keyword because reassociation happens
  immediately in such cases, before ERC even initializes any modules.
  Grafting can also happen in target buffers, most often after a user
  reconnects under a new nick and conducts business in the same channels
  and queries as before, only to renick _back_ to the previous nick via a
  ‘/nick oldme’ or similar.  As of version 5.7, ERC retains the current
  buffer's permanent value in all such situations, meaning ERC ignores
  permanent values from previous buffers and retains default values
  assigned during module initialization.

     Moving on, if your module needs to bind keys, define its keymap
  _before_ the module itself, and use the standard minor-mode naming
  convention of ‘erc-my-module-mode-map’.

       (defvar-keymap erc-view-mode-map
         :doc "Keymap for `view-mode' in ERC."
         "<remap> <delete-backward-char>" #'erc-view--enable-on-backspace)

       (defvar-keymap erc-view-mode-overriding-map
         :parent view-mode-map
         :doc "Overriding keymap for `view-mode' when `erc-view-mode' is active.
       Hitting \\`<RET>' atop a button prompts for an action by default.  Use
       \\`C-j' or \\`j' for scrolling up by a line."
         "C" nil ; View-kill-and-leave
         "E" #'erc-view--exit-to-bottom ; View-exit-and-edit
         "Q" nil ; View-quit-all
         "k" #'View-scroll-line-backward ; Vim backwards line
         "j" #'View-scroll-line-forward ; Vim forwards line
         "S-<return>" #'View-scroll-line-backward)

     You'll almost always want to define your module as buffer-local.  Do
  this by including a ‘localp’ flag as the final parameter to
  ‘define-erc-module’, after the “disable body”.  If your module only
  operates in one kind of buffer, disable it elsewhere in the “enable
  body”.  For example, if it should only run in server buffers, disable it
  in target buffers by doing something like ‘(if (erc-target)
  (erc-my-module-mode -1) (erc-my-module--setup))’.  And in all cases,
  please remember to mention the module's intended “scope” in the doc
  string.  Some informal adjectives that may help with that are:

     • query-local
     • channel-local
     • target-local (query or channel)
     • server-local
     • session-local (server and target)
     • buffer-local (server or target)

  You may also wish to mention this in the Custom group's doc string.

       (define-erc-module view nil
         "Enable `view-mode' if it was on previously.
       This module is buffer-local.  If you also use the `scrolltobottom'
       module, you probably want to enable the option `erc-scrolltobottom-all'."
         ((add-hook 'view-mode-hook #'erc-view--remember 0 t)
          (add-hook 'post-command-hook #'erc-view--enforce-prompt-boundary 0 t)
          (setf (alist-get 'view-mode minor-mode-overriding-map-alist)
                erc-view-mode-overriding-map)
          (unless (local-variable-p 'erc-view--enabled-p)
            (setq-local erc-view--enabled-p nil))
          (view-mode (if erc-view--enabled-p +1 -1)))
         ((kill-local-variable 'erc-view--enabled-p)
          (remove-hook 'post-command-hook #'erc-view--enforce-prompt-boundary t)
          (remove-hook 'view-mode-hook #'erc-view--remember t)
          (setf (alist-get 'view-mode minor-mode-overriding-map-alist nil 'remove)
                nil))
         localp)

     Always define your module early, before any code that refers to its
  mode command or minor-mode variable.

       (defun erc-view--enable-on-backspace (lines)
         "Enable `view-mode' at the prompt by hitting \\`<backspace>'."
         (interactive "P")
         (if (and erc-view-backspace-at-prompt-scrolls-down (not view-mode)
                  (= (point) erc-input-marker))
             (progn
               (view-mode +1)
               (View-scroll-page-backward lines))
           (call-interactively #'delete-backward-char)))

       (defun erc-view--enforce-prompt-boundary ()
         "Enable or disable `view-mode' when crossing prompt boundary."
         (when-let*
             ((new (if (>= (point) erc-input-marker)
                       (and view-mode erc-view-disable-when-entering-prompt -1)
                     (and (not view-mode) erc-view-enable-when-exiting-prompt +1))))
           (run-at-time 0 nil (lambda (buffer new)
                                (with-current-buffer buffer (view-mode new)))
                        (current-buffer) new)))

       (defun erc-view--exit-to-bottom ()
         "Scroll to prompt, exit `view-mode', and move to EOB."
         (interactive)
         (let (view-no-disable-on-exit)
           (View-scroll-to-buffer-end)
           (View-exit)
           (goto-char (point-max))))

       (defun erc-view--remember ()
         "Remember the value of `view-mode'.
       Disable `erc-move-to-prompt-setup' locally when `view-mode' is enabled."
         (cl-assert (local-variable-p 'erc-view--enabled-p))
         (setq erc-view--enabled-p view-mode)
         (when erc-move-to-prompt-mode
           (if view-mode
               (remove-hook 'pre-command-hook #'erc-move-to-prompt t)
             (erc-move-to-prompt-setup))))

     Don't forget to ‘provide’ your module so that ‘erc-update-modules’
  can find it.

       (provide 'erc-view)

       ;;; erc-view.el ends here

     Mimicking the above should just about cover most use cases.  If your
  module isn't loading correctly, it's likely a naming, layout, or
  packaging issue.  If you _must_ defy the convention recommended earlier
  regarding a library-group-module correspondence or if you've designed
  your module mainly to be toggled interactively rather than added to
  ‘erc-modules’, try placing a line like the following above the module's
  definition.

       ;;;###autoload(autoload 'erc-my-module-mode "erc-my-module" nil t)

  Just remember, doing so means you'll need to (re)generate the autoload
  file when hacking locally (*note (emacs)Fetching Package Sources::).

>From bdb72a74a30f23ba4181d054639c75ee89e54095 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Wed, 12 Feb 2025 21:11:51 -0800
Subject: [PATCH 1/2] [5.7] Split ERC module documentation into subnodes

* doc/misc/erc.texi: Add "Modules" section to the main detailed menu.
(Modules): Promote "Local Modules" and "Module Loading" subheadings to
sections and proper nodes.  Rename "Local Modules" to "Module Scope" but
retain anchor for compatibility.
---
 doc/misc/erc.texi | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 1c0afa3b300..818693db2f8 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -75,6 +75,11 @@ Top
 * Sample Session::              Example of connecting to the @samp{#emacs} channel
 * Special Features::            Differences from standalone IRC clients
 
+Advanced Module Topics
+
+* Scope: Module Scope.          Differences between module types.
+* Loading: Module Loading.      How ERC loads modules.
+
 Advanced Usage
 
 * Connecting::                  Ways of connecting to an IRC server.
@@ -614,9 +619,13 @@ Modules
 At present, the only such module is @code{networks}, whose library ERC
 always loads anyway.
 
+@c Advanced module topics and individual module usage.
+
 @anchor{Local Modules}
-@subheading Local Modules
+@node Module Scope
+@section Scope
 @cindex local modules
+@cindex module scope
 
 @c Earlier language in code comments, commit messages, and tracker
 @c discussions used to describe a local module as being "active" in a
@@ -697,10 +706,8 @@ Modules
 unlike global toggles, none of these ever mutates @code{erc-modules}.

-@c FIXME add section to Advanced chapter for creating modules, and
-@c move this there.
-@anchor{Module Loading}
-@subheading Loading
+@node Module Loading
+@section Loading
 @cindex module loading
 
 ERC loads internal modules in alphabetical order and third-party
-- 
2.48.1

>From 074bd8ea6ed4f57fde2e48c21c2df5b9813f5fbf Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Wed, 12 Feb 2025 21:11:51 -0800
Subject: [PATCH 2/2] [5.7] Add module example to ERC's documentation

* doc/misc/erc.texi (Module Example): New section under the Modules
chapter.  (Bug#57955)
---
 doc/misc/erc.texi | 237 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 237 insertions(+)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 818693db2f8..3f63b530f83 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -79,6 +79,7 @@ Top
 
 * Scope: Module Scope.          Differences between module types.
 * Loading: Module Loading.      How ERC loads modules.
+* Example: Module Example.      An example module.
 
 Advanced Usage
 
@@ -761,6 +762,242 @@ Module Loading
 incorrectly, with built-in modules moved from the predefined checklist
 to the user-provided free-form area.
 
+@node Module Example
+@section Example
+@cindex module example
+
+This is a walk-through of a working module presented in separate chunks.
+If you'd prefer to view it as a whole, you can install it as a
+third-party package through ERC's devel archive:
+@uref{https://emacs-erc.gitlab.io/bugs/archive/erc-view.html}.
+
+@lisp
+;;; erc-view.el -- Automatic view-mode for ERC -*- lexical-binding: t; -*-
+
+;; Maintainer: The ERC Maintainers <emacs-erc@@gnu.org>
+;; Keywords: convenience
+;; Version: 0.1
+;; Package-Requires: ((emacs "30.1"))
+;; URL: https://gitlab.com/emacs-erc/erc-view
+
+;;; Commentary:
+
+;; This is a demo local module for ERC.  It arranges for automatically
+;; enabling `view-mode' when leaving the prompt area and automatically
+;; disabling it when reentering.  It also ensures `view-mode' stays
+;; enabled or disabled when reconnecting.
+
+;;; Code:
+@end lisp
+
+You need to import ERC's main library somehow. The easiest way is
+directly, via a simple @code{(require 'erc)}, although this module does
+so indirectly because it also uses definitions from @file{erc-goodies}:
+
+@lisp
+(require 'erc-goodies)
+(require 'view)
+@end lisp
+
+Avoid headaches by aligning the name of your module with its containing
+library and Custom group.  It's best to have one group and one module
+per library.
+
+@lisp
+(defgroup erc-view nil
+  "Automatically enter and exit `view-mode' in ERC."
+  :version "0.1"
+  :group 'erc)
+
+(defcustom erc-view-enable-when-exiting-prompt t
+  "Whether to enable `view-mode' when exiting the prompt area."
+  :type 'boolean)
+
+(defcustom erc-view-disable-when-entering-prompt t
+  "Whether to disable `view-mode' when entering the prompt area."
+  :type 'boolean)
+
+(defcustom erc-view-backspace-at-prompt-scrolls-down t
+  "Whether a \\`<backspace>' at the prompt scrolls down to enter `view-mode'."
+  :type 'boolean)
+@end lisp
+
+You'll almost always want to define internal variables as buffer-local.
+
+@lisp
+(defvar-local erc-view--enabled-p nil
+  "Current reconnect-aware activation state of `view-mode'.")
+@end lisp
+
+@noindent
+In some cases, you may need a variable's value to survive the
+reapplication of ERC's @code{major-mode} performed in each reassociated
+buffer upon reconnecting.  Do this by leveraging the
+@code{permanent-local} symbol property.  (@pxref{Creating
+Buffer-Local,,,elisp,}.)
+
+@lisp
+(put 'erc-view--enabled-p 'permanent-local t)
+@end lisp
+
+@noindent
+There are a few caveats regarding the durability of permanent values.
+By convention, disabling a module's minor mode kills local bindings.
+Mode commands, like @code{erc-view-mode}, do so in the current buffer
+only, while unidirectional ones, like @code{erc-view-mode-disable}, do
+so connection-wide.
+
+There are also occasions in which persistence is undefined, most notably
+when @dfn{grafting} an old buffer's contents onto a current buffer.
+This occurs in server buffers upon @dfn{logical connection} (at
+@samp{MOTD}'s end), when a user reconnects with a new invocation of an
+entry-point command, like @code{erc-tls}, instead of via the
+auto-reconnect facility or by issuing a @samp{/reconnect} at the prompt.
+Unaffected are entry-point invocations that include an @code{:id}
+keyword because reassociation happens immediately in such cases, before
+ERC even initializes any modules.  Grafting can also happen in target
+buffers, most often after a user reconnects under a new nick and
+conducts business in the same channels and queries as before, only to
+renick @emph{back} to the previous nick via a @samp{/nick oldme} or
+similar.  As of version 5.7, ERC retains the current buffer's permanent
+value in all such situations, meaning ERC ignores permanent values from
+previous buffers and retains default values assigned during module
+initialization.
+
+Moving on, if your module needs to bind keys, define its keymap
+@emph{before} the module itself, and use the standard minor-mode naming
+convention of @code{erc-my-module-mode-map}.
+
+@lisp
+(defvar-keymap erc-view-mode-map
+  :doc "Keymap for `view-mode' in ERC."
+  "<remap> <delete-backward-char>" #'erc-view--enable-on-backspace)
+
+(defvar-keymap erc-view-mode-overriding-map
+  :parent view-mode-map
+  :doc "Overriding keymap for `view-mode' when `erc-view-mode' is active.
+Hitting \\`<RET>' atop a button prompts for an action by default.  Use
+\\`C-j' or \\`j' for scrolling up by a line."
+  "C" nil ; View-kill-and-leave
+  "E" #'erc-view--exit-to-bottom ; View-exit-and-edit
+  "Q" nil ; View-quit-all
+  "k" #'View-scroll-line-backward ; Vim backwards line
+  "j" #'View-scroll-line-forward ; Vim forwards line
+  "S-<return>" #'View-scroll-line-backward)
+@end lisp
+
+You'll almost always want to define your module as buffer-local.  Do
+this by including a @code{localp} flag as the final parameter to
+@code{define-erc-module}, after the @dfn{disable body}.  If your module
+only operates in one kind of buffer, disable it elsewhere in the
+@dfn{enable body}.  For example, if it should only run in server
+buffers, disable it in target buffers by doing something like @code{(if
+(erc-target) (erc-my-module-mode -1) (erc-my-module--setup))}.  And in
+all cases, please remember to mention the module's intended @dfn{scope}
+in the doc string.  Some informal adjectives that may help with that
+are:
+
+@itemize
+@item query-local
+@item channel-local
+@item target-local (query or channel)
+@item server-local
+@item session-local (server and target)
+@item buffer-local (server or target)
+@end itemize
+
+@noindent
+You may also wish to mention this in the Custom group's doc string.
+
+@lisp
+(define-erc-module view nil
+  "Enable `view-mode' if it was on previously.
+This module is buffer-local.  If you also use the `scrolltobottom'
+module, you probably want to enable the option `erc-scrolltobottom-all'."
+  ((add-hook 'view-mode-hook #'erc-view--remember 0 t)
+   (add-hook 'post-command-hook #'erc-view--enforce-prompt-boundary 0 t)
+   (setf (alist-get 'view-mode minor-mode-overriding-map-alist)
+         erc-view-mode-overriding-map)
+   (unless (local-variable-p 'erc-view--enabled-p)
+     (setq-local erc-view--enabled-p nil))
+   (view-mode (if erc-view--enabled-p +1 -1)))
+  ((kill-local-variable 'erc-view--enabled-p)
+   (remove-hook 'post-command-hook #'erc-view--enforce-prompt-boundary t)
+   (remove-hook 'view-mode-hook #'erc-view--remember t)
+   (setf (alist-get 'view-mode minor-mode-overriding-map-alist nil 'remove)
+         nil))
+  localp)
+@end lisp
+
+Always define your module early, before any code that refers to its mode
+command or minor-mode variable.
+
+@lisp
+(defun erc-view--enable-on-backspace (lines)
+  "Enable `view-mode' at the prompt by hitting \\`<backspace>'."
+  (interactive "P")
+  (if (and erc-view-backspace-at-prompt-scrolls-down (not view-mode)
+           (= (point) erc-input-marker))
+      (progn
+        (view-mode +1)
+        (View-scroll-page-backward lines))
+    (call-interactively #'delete-backward-char)))
+
+(defun erc-view--enforce-prompt-boundary ()
+  "Enable or disable `view-mode' when crossing prompt boundary."
+  (when-let*
+      ((new (if (>= (point) erc-input-marker)
+                (and view-mode erc-view-disable-when-entering-prompt -1)
+              (and (not view-mode) erc-view-enable-when-exiting-prompt +1))))
+    (run-at-time 0 nil (lambda (buffer new)
+                         (with-current-buffer buffer (view-mode new)))
+                 (current-buffer) new)))
+
+(defun erc-view--exit-to-bottom ()
+  "Scroll to prompt, exit `view-mode', and move to EOB."
+  (interactive)
+  (let (view-no-disable-on-exit)
+    (View-scroll-to-buffer-end)
+    (View-exit)
+    (goto-char (point-max))))
+
+(defun erc-view--remember ()
+  "Remember the value of `view-mode'.
+Disable `erc-move-to-prompt-setup' locally when `view-mode' is enabled."
+  (cl-assert (local-variable-p 'erc-view--enabled-p))
+  (setq erc-view--enabled-p view-mode)
+  (when erc-move-to-prompt-mode
+    (if view-mode
+        (remove-hook 'pre-command-hook #'erc-move-to-prompt t)
+      (erc-move-to-prompt-setup))))
+@end lisp
+
+Don't forget to @code{provide} your module so that
+@code{erc-update-modules} can find it.
+
+@lisp
+(provide 'erc-view)
+
+;;; erc-view.el ends here
+
+@end lisp
+Mimicking the above should just about cover most use cases.  If your
+module isn't loading correctly, it's likely a naming, layout, or
+packaging issue.  If you @emph{must} defy the convention recommended
+earlier regarding a library-group-module correspondence or if you've
+designed your module mainly to be toggled interactively rather than
+added to @code{erc-modules}, try placing a line like the following above
+the module's definition.
+
+@lisp
+;;;###autoload(autoload 'erc-my-module-mode "erc-my-module" nil t)
+@end lisp
+
+@noindent
+Just remember, doing so means you'll need to (re)generate the autoload
+file when hacking locally (@pxref{Fetching Package Sources,,, emacs,}).
+
+
 @c PRE5_4: Document every option of every module in its own subnode

-- 
2.48.1