Subject: bug#78693: 14.0.9; Folding of math macros with a function spec is broken

From: "Paul D. Nelson" <[email-masked]>
Date: Sun, 06 Jul 2025 14:32:45 +0200

Hi Rahguzar,

I thought about the further edge cases you raised.  The attached patch
is what I came up with.

You mentioned the following problem: when folding \begin macros, you
want to consume optional arguments following (e.g.) \begin{theorem} but
not (e.g.) \begin{equation}, so as to avoid errors in, e.g.,

\begin{equation}
  [X, Y] = Z
\end{equation}

With the attached patch, you can do so as follows:

--8<---------------cut here---------------start------------->8---
(defun my-equation-env-p (args)
  "Return non-nil when ARGS describes an equation environment.
Return non-nil if the list ARGS is of the form

  '(\"{env}\")

where ENV is an equation environment, such as equation, align, gather,
multline or their starred variants."
  (and (= 1 (length args))
       (let* ((envs (LaTeX--math-environment-list))
              (re (concat "{" (regexp-opt envs) "}")))
         (string-match-p re (car args)))))

(setq-local TeX-fold-macro-spec-list
            `((("⬖ {1}" . my-equation-env-p) ("begin")) 
              (("⬗ {1}" . 1) ("end"))))
--8<---------------cut here---------------end--------------->8---

This says that when determining the folding extent of a \begin macro for
a display math environment, we stop after the first argument (which is
just the environment name).

As before, the "1" signature in the \end macro has the effect that in

\end{equation}
[blah]

we do not fold the trailing "[blah]".

Edge cases such as

\begin{equation}
  \label{eq:blah} [X, Y] = Z
\end{equation}

are a bit subtler: with some packages, \label can accept optional
arguments, so we can't avoid such cases simply by restricting the number
of optional and required arguments.  Instead, we use the macro spec list
entry

(("[l]" . TeX-fold-stop-after-first-required) ("label"))

with the following predicate:

(defun TeX-fold-stop-after-first-required (args)
  "Return nil when final element of ARGS starts with \"{\"."
  (and args (string-prefix-p "{" (car (last args)))))

With this, when we fold \label macros, we stop consuming after the first
required argument that we encounter.  Thus, \label[...][...]{...}  folds
correctly, while \label[...]{...}[...] stops after the {...}.

I can't think of any other edge cases.  I think this patch is now ready
to ship, although I'd welcome any feedback.

Thanks, best,

Paul

>From 34ea7e6dfcd53ce863f79b6222699627873c5f83 Mon Sep 17 00:00:00 2001
From: Paul Nelson <[email-masked]>
Date: Sun, 15 Jun 2025 12:24:11 +0200
Subject: [PATCH] Add signature support for macro folding

Support signature restrictions to limit the arguments consumed
when folding LaTeX macros (bug#78693).

* tex.el (TeX-find-macro-boundaries, TeX-find-macro-end)
(TeX-find-macro-end-helper): Add optional SIGNATURE argument to
restrict allowed arguments.

* tex-fold.el (TeX-fold--spec-type): New internal constant.
(TeX-fold-macro-spec-list, TeX-fold-math-spec-list): Add
signatures to default folding specifications.  Update docstring
and type specification to document new signature format.
(TeX-fold-region-macro-or-env, TeX-fold-item): Extract signature
from spec and pass to 'TeX-fold-item-end'.
(TeX-fold-item-end): Add optional SIGNATURE parameter.  Remove
special handling for math type, reverting d0a57d8d and
delegating instead to 'TeX-find-macro-end'.
(TeX-fold-stop-after-first-required): New folding signature
predicate.

* latex.el (LaTeX-fold-math-spec-list): Add "signature 0" to
each entry, reflecting that it is a macro with no arguments.

* doc/auctex.texi (Folding): Document the new feature.

* tex-fold.el :
(TeX-fold-macro-spec-list):
---
 doc/auctex.texi |  17 ++++++
 latex.el        |   2 +-
 tex-fold.el     | 159 ++++++++++++++++++++++++++++--------------------
 tex.el          | 126 ++++++++++++++++++++++++--------------
 4 files changed, 194 insertions(+), 110 deletions(-)

diff --git a/doc/auctex.texi b/doc/auctex.texi
index 90a8cf21..58d063f0 100644
--- a/doc/auctex.texi
+++ b/doc/auctex.texi
@@ -2958,6 +2958,23 @@ as a replacement for the macro.  Such functions typically return a string,
 but may also return the symbol @code{abort} to indicate that the macro
 should not be folded.
 
+Each specifier may instead be a cons cell @code{(@var{spec} . @var{sig})},
+where @var{spec} is as above and @var{sig} controls how many arguments are
+considered part of the macro to fold:
+@itemize @bullet
+@item
+@code{nil}: no limit (default).
+@item
+Integer @var{n}: stop after @var{n} total arguments.
+@item
+Cons @code{(@var{p} . @var{q})}: stop after @var{p} optional and
+@var{q} required arguments.
+@item
+Predicate function: called repeatedly with the list of argument blocks
+encountered thus far.  Returns non-@code{nil} to terminate scanning early.
+See the doc string of @code{TeX-find-macro-boundaries} for details.
+@end itemize
+
 The placeholder is made by copying the text from the buffer together with
 its properties, i.e.@: its face as well.  If fontification has not
 happened when this is done (e.g.@: because of lazy font locking) the
diff --git a/latex.el b/latex.el
index 5bff638b..94666fe8 100644
--- a/latex.el
+++ b/latex.el
@@ -6729,7 +6729,7 @@ char."
                                                             "Accents")))
                                   submenu)))
                       (when (and (stringp tex-token) (integerp uchar) noargp)
-                        `(,(char-to-string uchar) (,tex-token)))))
+                        `((,(char-to-string uchar) . 0) (,tex-token)))))
                   `((nil "to" "" 8594)
                     (nil "gets" "" 8592)
                     ,@LaTeX-math-default)))
diff --git a/tex-fold.el b/tex-fold.el
index 78e5b822..1f3f7b04 100644
--- a/tex-fold.el
+++ b/tex-fold.el
@@ -72,62 +72,83 @@ macros, `math' for math macros and `comment' for comments."
               (const :tag "Math Macros" math)
               (const :tag "Comments" comment)))
 
+(defconst TeX-fold--spec-type
+  '(choice
+    (string :tag "Display String")
+    (integer :tag "Number of argument" :value 1)
+    (function :tag "Function to execute")
+    (cons :tag "Spec with signature"
+          (choice (string :tag "Display String")
+                  (integer :tag "Number of argument" :value 1)
+                  (function :tag "Function to execute"))
+          (choice (const :tag "No restriction" nil)
+                  (integer :tag "Max total arguments")
+                  (cons :tag "Max optional and required"
+                        (integer :tag "Max optional arguments")
+                        (integer :tag "Max required arguments"))
+                  (function :tag "Predicate function"))))
+  "Type spec used by TeX-fold defcustoms.")
+
 (defcustom TeX-fold-macro-spec-list
-  '(("[f]" ("footnote" "marginpar"))
-    (TeX-fold-cite-display ("cite"))
-    ("[l]" ("label"))
-    ("[r]" ("ref" "pageref" "eqref" "footref"))
-    ("[i]" ("index" "glossary"))
-    ("[1]:||*" ("item"))
-    ("..." ("dots"))
-    ("(C)" ("copyright"))
-    ("(R)" ("textregistered"))
-    ("TM"  ("texttrademark"))
-    (TeX-fold-alert-display ("alert"))
-    (TeX-fold-textcolor-display ("textcolor"))
+  '((("[f]" . (1 . 1)) ("footnote" "marginpar"))
+    ((TeX-fold-cite-display . (2 . 1)) ("cite"))
+    (("[l]" . TeX-fold-stop-after-first-required) ("label"))
+    (("[r]" . 1) ("ref" "pageref" "eqref" "footref"))
+    (("[i]" . (1 . 1)) ("index" "glossary"))
+    (("[1]:||*" . (1 . 0)) ("item"))
+    (("..." . 0) ("dots"))
+    (("(C)" . 0) ("copyright"))
+    (("(R)" . 0) ("textregistered"))
+    (("TM" . 0)  ("texttrademark"))
+    ((TeX-fold-alert-display . 1) ("alert"))
+    ((TeX-fold-textcolor-display . (1 . 2)) ("textcolor"))
     (TeX-fold-begin-display ("begin"))
-    (TeX-fold-end-display ("end"))
+    ((TeX-fold-end-display . 1) ("end"))
     (1 ("part" "chapter" "section" "subsection" "subsubsection"
         "paragraph" "subparagraph"
         "part*" "chapter*" "section*" "subsection*" "subsubsection*"
-        "paragraph*" "subparagraph*"
-        "emph" "textit" "textsl" "textmd" "textrm" "textsf" "texttt"
-        "textbf" "textsc" "textup")))
+        "paragraph*" "subparagraph*"))
+    ((1 . (0 . 1)) ("emph" "textit" "textsl" "textmd" "textrm" "textsf" "texttt"
+                    "textbf" "textsc" "textup")))
   "List of replacement specifiers and macros to fold.
 
-The first element of each item can be a string, an integer or a
-function symbol.  The second element is a list of macros to fold
-without the leading backslash.
-
-If the first element is a string, it will be used as a display
-replacement for the whole macro.  Numbers in braces, brackets,
-parens or angle brackets will be replaced by the respective macro
-argument.  For example \"{1}\" will be replaced by the first
-mandatory argument of the macro.  One can also define
-alternatives within the specifier which are used if an argument
-is not found.  Alternatives are separated by \"||\".  They are
-most useful with optional arguments.  As an example, the default
-specifier for \\item is \"[1]:||*\" which means that if there is
-an optional argument, its value is shown followed by a colon.  If
-there is no optional argument, only an asterisk is used as the
-display string.
-
-If the first element is an integer, the macro will be replaced by
-the respective macro argument.
-
-If the first element is a function symbol, the function will be
-called with all mandatory arguments of the macro and the result
-of the function call will be used as a replacement for the macro.
-Such functions typically return a string, but may also return the
-symbol `abort' to indicate that the macro should not be folded.
-
-Setting this variable does not take effect immediately.  Use
-Customize or reset the mode."
-  :type '(repeat (group (choice (string :tag "Display String")
-                                (integer :tag "Number of argument" :value 1)
-                                (function :tag "Function to execute"))
+The first element is of the form SPEC or (SPEC . SIG), where SPEC can be
+a string, an integer or a function symbol and SIG is described below.
+The second element is a list of macros to fold without the leading
+backslash.
+
+If SPEC is a string, it will be used as a display replacement for the
+whole macro.  Numbers in braces, brackets, parens or angle brackets will
+be replaced by the respective macro argument.  For example \"{1}\" will
+be replaced by the first mandatory argument of the macro.  One can also
+define alternatives within the specifier which are used if an argument
+is not found.  Alternatives are separated by \"||\".  They are most
+useful with optional arguments.  As an example, the default specifier
+for \\item is \"[1]:||*\" which means that if there is an optional
+argument, its value is shown followed by a colon.  If there is no
+optional argument, only an asterisk is used as the display string.
+
+If SPEC is an integer, the macro will be replaced by the respective
+macro argument.
+
+If SPEC is a function symbol, the function will be called with all
+mandatory arguments of the macro and the result of the function call
+will be used as a replacement for the macro.  Such functions typically
+return a string, but may also return the symbol `abort' to indicate that
+the macro should not be folded.
+
+SIG optionally restricts how many macro arguments are consumed.  It
+should be of the form required by the SIGNATURE argument of
+`TeX-find-macro-boundaries'.  For example, if SIGNATURE is an integer n,
+then at most n total arguments are consumed, while if it is a cons
+cell (p . q), then at most p optional and q required arguments are
+allowed.
+
+Setting this variable does not take effect immediately.  Use Customize
+or reset the mode."
+  :type `(repeat (group ,TeX-fold--spec-type
                         (repeat :tag "Macros" (string))))
-  :package-version '(auctex . "14.0.8"))
+  :package-version '(auctex . "14.0.9"))
 
 (defvar-local TeX-fold-macro-spec-list-internal nil
   "Internal list of display strings and macros to fold.
@@ -153,9 +174,7 @@ and <mode-prefix>-fold-env-spec-list.")
 
 (defcustom TeX-fold-math-spec-list nil
   "List of display strings and math macros to fold."
-  :type '(repeat (group (choice (string :tag "Display String")
-                                (integer :tag "Number of argument" :value 1)
-                                (function :tag "Function to execute"))
+  :type `(repeat (group ,TeX-fold--spec-type
                         (repeat :tag "Math Macros" (string)))))
 
 (defvar-local TeX-fold-math-spec-list-internal nil
@@ -437,9 +456,14 @@ for macros and `math' for math macros."
                                                (string (char-after
                                                         (match-end 0)))))))
                 (let* ((item-start (match-beginning 0))
-                       (display-string-spec (cadr (assoc item-name
-                                                         fold-list)))
-                       (item-end (TeX-fold-item-end item-start type))
+                       (spec-sig? (cadr (assoc item-name fold-list)))
+                       ;; spec-sig? is of the form SPEC or (SPEC . SIG).
+                       (display-string-spec (if (consp spec-sig?)
+                                                (car spec-sig?)
+                                              spec-sig?))
+                       (sig (when (consp spec-sig?)
+                              (cdr spec-sig?)))
+                       (item-end (TeX-fold-item-end item-start type sig))
                        (ov (TeX-fold-make-overlay item-start item-end type
                                                   display-string-spec)))
                   (TeX-fold-hide-item ov))))))))))
@@ -536,7 +560,7 @@ Return non-nil if an item was found and folded, nil otherwise."
                                  TeX-fold-math-spec-list-internal)
                                 (t TeX-fold-macro-spec-list-internal)))
                fold-item
-               (display-string-spec
+               (spec-sig?
                 (or (catch 'found
                       (while fold-list
                         (setq fold-item (car fold-list))
@@ -549,7 +573,13 @@ Return non-nil if an item was found and folded, nil otherwise."
                       (if (eq type 'env)
                           TeX-fold-unspec-env-display-string
                         TeX-fold-unspec-macro-display-string))))
-               (item-end (TeX-fold-item-end item-start type))
+               ;; spec-sig? is of the form SPEC or (SPEC . SIG).
+               (display-string-spec (if (consp spec-sig?)
+                                        (car spec-sig?)
+                                      spec-sig?))
+               (sig (when (consp spec-sig?)
+                      (cdr spec-sig?)))
+               (item-end (TeX-fold-item-end item-start type sig))
                (ov (TeX-fold-make-overlay item-start item-end type
                                           display-string-spec)))
           (TeX-fold-hide-item ov))))))
@@ -882,10 +912,12 @@ display property."
       (overlay-put ov 'display display-string))
     ov))
 
-(defun TeX-fold-item-end (start type)
+(defun TeX-fold-item-end (start type &optional signature)
   "Return the end of an item of type TYPE starting at START.
 TYPE can be either `env' for environments, `macro' for macros or
-`math' for math macros."
+`math' for math macros.
+Optional SIGNATURE, as in `TeX-find-macro-boundaries', restricts the
+allowed arguments of LaTeX macros."
   (save-excursion
     (cond ((and (eq type 'env)
                 (eq major-mode 'ConTeXt-mode))
@@ -901,15 +933,9 @@ TYPE can be either `env' for environments, `macro' for macros or
            (goto-char (1+ start))
            (LaTeX-find-matching-end)
            (point))
-          ((eq type 'math)
-           (goto-char (1+ start))
-           (if (zerop (skip-chars-forward "A-Za-z@"))
-               (forward-char)
-             (skip-chars-forward "*"))
-           (point))
           (t
            (goto-char start)
-           (TeX-find-macro-end)))))
+           (TeX-find-macro-end signature)))))
 
 (defun TeX-fold-overfull-p (ov-start ov-end display-string)
   "Return t if an overfull line will result after adding an overlay.
@@ -1070,6 +1096,9 @@ breaks will be replaced by spaces."
       (dolist (ov overlays)
         (TeX-fold-hide-item ov)))))
 
+(defun TeX-fold-stop-after-first-required (args)
+  "Return nil when final element of ARGS starts with \"{\"."
+  (and args (string-prefix-p "{" (car (last args)))))
 
 ;;; Removal
 
diff --git a/tex.el b/tex.el
index 6f8267ac..de2b4505 100644
--- a/tex.el
+++ b/tex.el
@@ -5790,11 +5790,23 @@ If LIMIT is non-nil, do not search further up than this position
 in the buffer."
   (TeX-find-balanced-brace -1 depth limit))
 
-(defun TeX-find-macro-boundaries (&optional lower-bound)
+(defun TeX-find-macro-boundaries (&optional lower-bound signature)
   "Return a cons containing the start and end of a macro.
 If LOWER-BOUND is given, do not search backward further than this
 point in buffer.  Arguments enclosed in brackets or braces are
-considered part of the macro."
+considered part of the macro.
+
+If SIGNATURE is given, restrict the total number of arguments.  If
+SIGNATURE is an integer N, allow at most N total arguments.  If
+SIGNATURE is a cons cell (P . Q), allow at most P optional and Q
+required arguments.
+
+Finally, SIGNATURE may be a function, called before each new argument is
+consumed with a single list argument consisting of the argument blocks
+encountered thus far.  For example, with point before
+\"\\begin{equation}\", it would be called first with the empty list ()
+and then with the single element list (\"{equation}\").  If SIGNATURE
+returns non-nil, then no further macro arguments are consumed."
   ;; FIXME: Pay attention to `texmathp-allow-detached-args' and
   ;; `reftex-allow-detached-macro-args'.
   ;; Should we handle cases like \"{o} and \\[3mm] (that is, a macro
@@ -5839,16 +5851,17 @@ considered part of the macro."
       ;; Search forward for the end of the macro.
       (when start-point
         (save-excursion
-          (goto-char (TeX-find-macro-end-helper start-point))
+          (goto-char (TeX-find-macro-end-helper start-point signature))
           (if (< orig-point (point))
               (cons start-point (point))
             nil))))))
 
-(defun TeX-find-macro-end-helper (start)
+(defun TeX-find-macro-end-helper (start &optional signature)
   "Find the end of a macro given its START.
 START is the position just before the starting token of the macro.
 If the macro is followed by square brackets or curly braces,
-those will be considered part of it."
+those will be considered part of it.  SIGNATURE, as in
+`TeX-find-macro-boundaries', restricts how many arguments are allowed."
   (save-excursion
     (save-match-data
       (catch 'found
@@ -5856,43 +5869,67 @@ those will be considered part of it."
         (if (zerop (skip-chars-forward "A-Za-z@"))
             (forward-char)
           (skip-chars-forward "*"))
-        (while (not (eobp))
-          (cond
-           ;; Skip over pairs of square brackets
-           ((or (looking-at "[ \t]*\n?[ \t]*\\(\\[\\)") ; Be conservative: Consider
+        (let* ((max-tot (and (integerp signature) signature))
+               (max-opt (and (consp signature) (car signature)))
+               (max-req (and (consp signature) (cdr signature)))
+               (num-opt 0)
+               (num-req 0)
+               (sig-pred (when (functionp signature) signature))
+               last-arg-start last-args)
+          (while (not (eobp))
+            (when (or (and max-tot (>= (+ num-opt num-req) max-tot))
+                      (and sig-pred
+                           (progn
+                             (when last-arg-start
+                               (let ((arg (buffer-substring-no-properties
+                                           last-arg-start (point))))
+                                 (setq last-args (nconc last-args (list arg)))))
+                             (funcall sig-pred last-args))))
+              (throw 'found (point)))
+            (cond
+             ;; Skip over pairs of square brackets
+             ((or (looking-at "[ \t]*\n?[ \t]*\\(\\[\\)") ; Be conservative: Consider
                                         ; only consecutive lines.
-                (and (looking-at (concat "[ \t]*" TeX-comment-start-regexp))
-                     (save-excursion
-                       (forward-line 1)
-                       (looking-at "[ \t]*\\(\\[\\)"))))
-            (goto-char (match-beginning 1))
-            ;; Imitate `font-latex-find-matching-close', motivated by
-            ;; examples like \begin{enumerate}[a{]}].
-            (let ((syntax (TeX-search-syntax-table ?\[ ?\]))
-                  (parse-sexp-ignore-comments
-                   (not (derived-mode-p 'docTeX-mode))))
-              (modify-syntax-entry ?\{ "|" syntax)
-              (modify-syntax-entry ?\} "|" syntax)
-              (modify-syntax-entry ?\\ "/" syntax)
-              (condition-case nil
-                  (with-syntax-table syntax
-                    (forward-sexp))
-                (scan-error (throw 'found (point))))))
-           ;; Skip over pairs of curly braces
-           ((or (looking-at "[ \t]*\n?[ \t]*{") ; Be conservative: Consider
+                  (and (looking-at (concat "[ \t]*" TeX-comment-start-regexp))
+                       (save-excursion
+                         (forward-line 1)
+                         (looking-at "[ \t]*\\(\\[\\)"))))
+              (when (and max-opt (>= num-opt max-opt))
+                (throw 'found (point)))
+              (cl-incf num-opt)
+              (goto-char (match-beginning 1))
+              (setq last-arg-start (point))
+              ;; Imitate `font-latex-find-matching-close', motivated by
+              ;; examples like \begin{enumerate}[a{]}].
+              (let ((syntax (TeX-search-syntax-table ?\[ ?\]))
+                    (parse-sexp-ignore-comments
+                     (not (derived-mode-p 'docTeX-mode))))
+                (modify-syntax-entry ?\{ "|" syntax)
+                (modify-syntax-entry ?\} "|" syntax)
+                (modify-syntax-entry ?\\ "/" syntax)
+                (condition-case nil
+                    (with-syntax-table syntax
+                      (forward-sexp))
+                  (scan-error (throw 'found (point))))))
+             ;; Skip over pairs of curly braces
+             ((or (looking-at "[ \t]*\n?[ \t]*{") ; Be conservative: Consider
                                         ; only consecutive lines.
-                (and (looking-at (concat "[ \t]*" TeX-comment-start-regexp))
-                     (save-excursion
-                       (forward-line 1)
-                       (looking-at "[ \t]*{"))))
-            (goto-char (match-end 0))
-            (goto-char (or (TeX-find-closing-brace)
-                           ;; If we cannot find a regular end, use the
-                           ;; next whitespace.
-                           (save-excursion (skip-chars-forward "^ \t\n")
-                                           (point)))))
-           (t
-            (throw 'found (point)))))
+                  (and (looking-at (concat "[ \t]*" TeX-comment-start-regexp))
+                       (save-excursion
+                         (forward-line 1)
+                         (looking-at "[ \t]*{"))))
+              (when (and max-req (>= num-req max-req))
+                (throw 'found (point)))
+              (cl-incf num-req)
+              (goto-char (match-end 0))
+              (setq last-arg-start (1- (point)))
+              (goto-char (or (TeX-find-closing-brace)
+                             ;; If we cannot find a regular end, use the
+                             ;; next whitespace.
+                             (save-excursion (skip-chars-forward "^ \t\n")
+                                             (point)))))
+             (t
+              (throw 'found (point))))))
         ;; Make sure that this function does not return nil, even
         ;; when the above `while' loop is totally skipped. (bug#35638)
         (throw 'found (point))))))
@@ -5904,11 +5941,12 @@ in buffer.  Arguments enclosed in brackets or braces are
 considered part of the macro."
   (car (TeX-find-macro-boundaries limit)))
 
-(defun TeX-find-macro-end ()
+(defun TeX-find-macro-end (&optional signature)
   "Return the end of a macro.
-Arguments enclosed in brackets or braces are considered part of
-the macro."
-  (cdr (TeX-find-macro-boundaries)))
+Arguments enclosed in brackets or braces are considered part of the
+macro.  SIGNATURE, as in `TeX-find-macro-boundaries', restricts how many
+arguments are allowed."
+  (cdr (TeX-find-macro-boundaries nil signature)))
 
 (defun TeX-search-forward-unescaped (string &optional bound noerror)
   "Search forward from point for unescaped STRING.
-- 
2.39.3 (Apple Git-145)