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

From: "Paul D. Nelson" <[email-masked]>
Date: Sun, 03 Aug 2025 15:32:13 +0200

Rahguzar <[email-masked]> writes:

> Hi Paul,
>
> "Paul D. Nelson" <[email-masked]> writes:
>
>> I can't think of any other edge cases.  I think this patch is now ready
>> to ship, although I'd welcome any feedback.
>
> I also think that it is ready to ship. Thanks for all your work on this.

Thanks Raghuzar.  Attaching an updated patch rebased to master.  Since
it's a non-local change, I figure I'll wait for Arash or Ikumi to take a
quick sniff, then we can push and close.

Paul

>From 5eabc82a9a336dbf747efc138ea27f7cafa20dd0 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        |   3 +-
 tex-fold.el     | 165 ++++++++++++++++++++++++++++--------------------
 tex.el          | 126 +++++++++++++++++++++++-------------
 4 files changed, 198 insertions(+), 113 deletions(-)

diff --git a/doc/auctex.texi b/doc/auctex.texi
index d0d2e41c..eb692f70 100644
--- a/doc/auctex.texi
+++ b/doc/auctex.texi
@@ -2931,6 +2931,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 757172d7..a8ed0127 100644
--- a/latex.el
+++ b/latex.el
@@ -6729,7 +6729,8 @@ char."
                                                             "Accents")))
                                   submenu)))
                       (when (and (stringp tex-token) (integerp uchar) noargp)
-                        `(,(char-to-string uchar) (,tex-token)))))
+                        ;; Each of these macros accepts 0 total arguments.
+                        `((,(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 5ff9a6b1..775381e3 100644
--- a/tex-fold.el
+++ b/tex-fold.el
@@ -72,65 +72,86 @@ 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" "Cite"))
-    (TeX-fold-textcite-display ("textcite" "Textcite"))
-    (TeX-fold-parencite-display ("parencite" "Parencite"))
-    (TeX-fold-footcite-display ("footcite" "footcitetext"))
-    ("[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" "Cite"))
+    ((TeX-fold-textcite-display . (2 . 1)) ("textcite" "Textcite"))
+    ((TeX-fold-parencite-display . (2 . 1)) ("parencite" "Parencite"))
+    ((TeX-fold-footcite-display . (2 . 1)) ("footcite" "footcitetext"))
+    (("[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.
@@ -156,9 +177,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
@@ -440,9 +459,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))))))))))
@@ -539,7 +563,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))
@@ -552,7 +576,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))))))
@@ -907,10 +937,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))
@@ -926,15 +958,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.
@@ -1095,6 +1121,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 18438a81..cbd0bef1 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 is 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)