Subject: bug#78586: TeX-make-inline

From: "Paul D. Nelson" <[email-masked]>
Date: Sat, 31 May 2025 16:57:34 +0200

Hi Ikumi,

Thanks for the further feedback, which I've now incorporated.

I mentioned that this was the first of several commands I planned to
propose, in an attempt to upstream the most broadly useful stuff from
https://github.com/ultronozm/czm-tex-edit.el.

The next planned command was an "inverse" to LaTeX-make-inline, say
LaTeX-make-display, that converts inline math to display math.  I had
planned to propose that command in a separate bug, but the two are so
intertwined that I think it makes sense to treat them together.

The tricky part in designing LaTeX-make-display is that different users
may prefer different sorts of display math: \[..\], $$..$$,
equation/equation*/align/align*/(...).  Given that we don't want to add
too many new user options, it seemed best to provide a general
LaTeX-modify-math command, which the user can either invoke
interactively or specialize like so:

--8<---------------cut here---------------start------------->8---
(defun my-LaTeX-make-brackets ()
  "Convert math construct at point to \"\\=\\[..\\=\\]\"."
  (interactive)
  (LaTeX-modify-math "\\["))

(defun my-LaTeX-make-equation* ()
  "Convert math construct at point to \"equation*\"."
  (interactive)
  (LaTeX-modify-math "equation*"))

(defun my-LaTeX-toggle-numbered ()
  "Convert math construct at point to \"equation*\".
If the math construct is already \"equation*\", then toggle with the
numbered variant \"equation\"."
  (interactive)
  (unless (texmathp) (user-error "Not inside math"))
  (let ((current (car texmathp-why)))
    (LaTeX-modify-math
     (pcase current
       ("equation*" "equation")
       ("equation" "equation*")
       (_ "equation*")))))

(defun my-LaTeX-toggle-align ()
  "Toggle math environment at point between \"equation\" and \"align\"."
  (interactive)
  (unless (texmathp) (user-error "Not inside math"))
  (let ((current (car texmathp-why)))
    (LaTeX-modify-math
     (pcase current
       ("align*" "equation*")
       ("equation*" "align*")
       ("align" "equation")
       ("equation" "align")
       (_ "align*")))))
--8<---------------cut here---------------end--------------->8---

We could document some of these in the manual (where?), but leave their
precise implementation and binding to the user.

How does this plan sound?  The attached patch contains everything but
documentation concerning LaTeX-modify-math, for which I await feedback
on a location in the manual and the overall soundness of the approach.

Thanks, best,

Paul

>From 2bbfcad353be520f91496ce131830495541ddf04 Mon Sep 17 00:00:00 2001
From: Paul Nelson <[email-masked]>
Date: Thu, 29 May 2025 11:14:11 +0200
Subject: [PATCH] Add LaTeX-modify-math and LaTeX-make-inline

* latex.el (LaTeX--modify-math-1, LaTeX--closing): New helper
functions.
(LaTeX-modify-math, LaTeX-make-inline): New commands.

* tests/latex/latex-modify-math-test.el: New test file.
(latex-make-inline-test--with-temp-buffer): New test macro.
(LaTeX-modify-math-inline-bracket-period)
(LaTeX-modify-math-inline-double-dollar)
(LaTeX-modify-math-inline-electric-math)
(LaTeX-modify-math-inline-equation-env)
(LaTeX-modify-math-inline-noop)
(LaTeX-modify-math-inline-paren-to-dollar)
(LaTeX-modify-math-inline-multiline-equation)
(LaTeX-modify-math-inline-punctuation-semicolon)
(LaTeX-modify-math-inline-multiple-punctuation)
(LaTeX-modify-math-inline-whitespace-preservation)
(LaTeX-modify-math-inline-empty-lines)
(LaTeX-modify-math-dollar-to-bracket)
(LaTeX-modify-math-paren-to-double-dollar)
(LaTeX-modify-math-bracket-to-equation)
(LaTeX-modify-math-point-inline-to-display-after-content)
(LaTeX-modify-math-point-inline-to-display-before-content)
(LaTeX-modify-math-point-display-to-inline-after-content)
(LaTeX-modify-math-point-display-to-inline-before-content)
(LaTeX-modify-math-point-multiline-roundtrip): New test cases.

* doc/auctex.texi (Quotes): Document LaTeX-make-inline.
---
 doc/auctex.texi                       |  10 ++
 latex.el                              | 187 ++++++++++++++++++++++++
 tests/latex/latex-modify-math-test.el | 203 ++++++++++++++++++++++++++
 3 files changed, 400 insertions(+)
 create mode 100644 tests/latex/latex-modify-math-test.el

diff --git a/doc/auctex.texi b/doc/auctex.texi
index 0b486c8c..9602c062 100644
--- a/doc/auctex.texi
+++ b/doc/auctex.texi
@@ -484,6 +484,16 @@ to prevent unmatched dollar.
 Note that Texinfo mode does nothing special for @kbd{$}.  It inserts
 dollar sign(s) just in the same way as the other normal keys do.
 
+@AUCTeX{} provides the command @code{LaTeX-make-inline} which converts the
+display math environment at point to inline math.
+
+@deffn Command LaTeX-make-inline
+Convert @LaTeX{} display math environment at point to inline math.  This
+command replaces the enclosing math environment such as @samp{\[...\]} or
+@samp{\begin@{equation@}...\end@{equation@}} with the value of
+@code{TeX-electric-math} or @samp{$...$} by default.
+@end deffn
+
 @subheading Braces
 
 To avoid unbalanced braces, it is useful to insert them pairwise.  You
diff --git a/latex.el b/latex.el
index 797513f2..f6cc5b9d 100644
--- a/latex.el
+++ b/latex.el
@@ -9551,6 +9551,193 @@ no caption key is found, an error is issued.  See also the docstring of
                           "LARGE" "huge" "Huge")
   "List of LaTeX font size declarations.")
 
+(defun LaTeX--modify-math-1 (open close inline new-open new-close new-inline pos)
+  "Helper function for `LaTeX-modify-math'.
+OPEN and CLOSE are the current delimiters, NEW-OPEN and NEW-CLOSE are
+the new delimiters.  INLINE and NEW-INLINE are booleans indicating
+whether the current and new delimiters are inline or display math.
+Assume point is at the start of the current OPEN delimiter.  POS is a
+marker that keeps track of cursor position."
+  (let ((converting-to-inline (and (not inline) new-inline)))
+    (when converting-to-inline
+      ;; Join with previous line if non-blank.
+      (when (and (looking-back "\n[[:blank:]]*" (point-min))
+                 (> (line-beginning-position) (point-min))
+                 (save-excursion
+                   (forward-line -1)
+                   (not (looking-at "^[[:blank:]]*$"))))
+        (forward-char (length open))
+        (save-excursion (join-line))
+        (forward-char (- (length open)))))
+    (unless new-inline
+      ;; Ensure non-inline delimiters start on a blank line.
+      (unless (looking-back "\n[[:blank:]]*" (point-min))
+        (delete-horizontal-space)
+        (insert "\n")))
+    ;; Delete opening delimiter.
+    (delete-char (length open))
+    (let ((start (point)))
+      (search-forward close)
+      (when converting-to-inline
+        ;; Join with next line if non-blank.
+        (when (and (looking-at-p "[[:blank:]]*\n")
+                   (< (line-end-position) (point-max))
+                   (save-excursion
+                     (forward-line 1)
+                     (not (looking-at-p "^[[:blank:]]*$"))))
+          (join-line 'next)))
+      (unless new-inline
+        (unless (looking-at-p "[[:blank:]]*\n")
+          (save-excursion
+            (insert "\n"))))
+      ;; Delete closing delimiter.
+      (delete-char (- (length close)))
+      (save-restriction
+        (narrow-to-region start (point))
+        ;; Clear labels.
+        (goto-char (point-min))
+        (let ((re (concat
+                   "\\(?:"
+                   (if (bound-and-true-p reftex-label-regexps)
+                       (mapconcat #'identity reftex-label-regexps "\\|")
+                     (format "%slabel%s%s%s"
+                             (regexp-quote TeX-esc)
+                             TeX-grop "[^}]*" TeX-grcl))
+                   "\\)")))
+          (while (re-search-forward re nil t)
+            (replace-match "")))
+        ;; Delete leading and trailing whitespace.
+        (dolist (re '("\\`[ \t\n\r]+" "[ \t\n\r]+\\'"))
+          (goto-char (point-min))
+          (when (re-search-forward re nil t)
+            (replace-match "")))
+        (unless new-inline
+          (goto-char (point-min))
+          (insert "\n")
+          (goto-char (point-max))
+          (insert "\n"))
+        ;; Insert new opening delimiter.
+        (goto-char (point-min))
+        (insert new-open)
+        ;; Insert new closing delimiter
+        (goto-char (point-max))
+        (when (eq (point) (marker-position pos))
+          (set-marker-insertion-type pos (not 'advance)))
+        (when converting-to-inline
+          ;; Leave punctuation outside.
+          (while (looking-back "[.,;:!?]"
+                               (max (point-min) (- (point) 5)))
+            (backward-char)))
+        (insert new-close)
+        ;; Indent, including one line past the modified region.
+        (widen)
+        (end-of-line 2)
+        (indent-region start (point))))))
+
+(defun LaTeX--closing (type)
+  "Return closing delimiter corresponding to given `texmathp' TYPE.
+TYPE must be one of the (La)TeX symbols $, $$, \\( or \\=\\[, or a valid
+environment name.  Macros such as \\ensuremath are not supported."
+  (pcase type
+    ((or "$" "$$") type)
+    ("\\[" "\\]")
+    ("\\(" "\\)")
+    (_ (unless (assoc type (LaTeX-environment-list-filtered))
+         (error "Invalid or unsupported opening delimiter: %s" type))
+       (concat TeX-esc "end" TeX-grop type TeX-grcl))))
+
+(defun LaTeX-modify-math (&optional new-type)
+  "Modify the current math construct to NEW-TYPE.
+
+Interactively, prompt for NEW-TYPE from a list of inline math
+delimiters (\"$\", \"\\(\"), display math delimiters (\"$$\",
+\"\\=\\[\") and valid LaTeX environments (\"equation\", ...).
+
+Non-interactively, NEW-TYPE must be either
+- a string specifying the target delimiter or environment name, or
+- a cons cell ((OPEN . CLOSE) . INLINE), where OPEN and CLOSE are
+  delimiters and INLINE is non-nil if the math construct is to be
+  understood as inline.
+
+The function converts the math construct at point (inline, display, or
+environment) to the specified NEW-TYPE, preserving the content.  If
+point is not in a math construct, signal an error.  Clears any active
+previews at point before modification.
+
+Does not support modifying macro-based constructs such as \\ensuremath."
+  (interactive
+   (let* ((type (progn (texmathp) (car texmathp-why)))
+          (tbl (append '("$" "\\(" "$$" "\\[")
+                       (LaTeX-environment-list-filtered))))
+     (unless type (user-error "Not inside math"))
+     (LaTeX--closing type) ;; Check for errors.
+     (list (completing-read
+            (format "Convert %s → " type) tbl nil t nil nil
+            type))))
+  (let ((new-open (if (stringp new-type)
+                      new-type
+                    (caar new-type)))
+        (new-close (if (stringp new-type)
+                       (LaTeX--closing new-type)
+                     (cdar new-type)))
+        (new-inline (if (stringp new-type)
+                        (member new-type '("$" "\\("))
+                      (cdr new-type))))
+    (when (fboundp 'preview-clearout-at-point)
+      (preview-clearout-at-point))
+    (unless (called-interactively-p 'any)
+      (unless (texmathp) (error "Not inside math")))
+    (let ((type (car texmathp-why))
+          (math-start (cdr texmathp-why))
+          (pos (point-marker)))
+      (set-marker-insertion-type pos
+                                 (not
+                                  (and
+                                   (< (point) (point-max))
+                                   (save-excursion
+                                     (forward-char)
+                                     (not (texmathp))))))
+      (goto-char math-start)
+      (let* ((open (if (member type '("\\(" "$" "\\[" "$$"))
+                       type
+                     (concat TeX-esc "begin" TeX-grop type TeX-grcl)))
+             (close (LaTeX--closing type)))
+        (if (or (not (stringp new-type))
+                (member new-open '("$" "\\(" "\\[" "$$")))
+            ;; Conversion to inline or non-environment display.
+            (let* ((inline (member type '("$" "\\("))))
+              (LaTeX--modify-math-1 open close inline new-open new-close new-inline pos))
+          ;; Conversion to an environment.
+          (if (member type '("$" "\\(" "$$" "\\["))
+              (delete-char (length type))
+            (kill-line))
+          (push-mark (save-excursion
+                       (search-forward close)
+                       (delete-region (match-beginning 0) (match-end 0))
+                       (when (eq (point) (marker-position pos))
+                         (setq pos nil))
+                       (when (member type '("$" "\\("))
+                         (while (looking-at-p "[.,;:!?]")
+                           (forward-char)))
+                       (point)))
+          (activate-mark)
+          (LaTeX-insert-environment new-type)))
+      (when pos
+        (goto-char pos)))))
+
+(defun LaTeX-make-inline ()
+  "Convert LaTeX display math construct at point to inline math.
+Remove the enclosing math construct (such as \\=\\[...\\=\\] or
+\\begin{equation}...\\end{equation}) and replace it with inline math
+surrounded by `TeX-electric-math' if non-nil, or \"$...$\", fitting the
+result onto one line.  Finally, leave any trailing punctuation outside
+the math delimiters."
+  (interactive)
+  (LaTeX-modify-math
+   (if TeX-electric-math
+       (cons TeX-electric-math 'inline)
+     "$")))
+
 (provide 'latex)
 
 ;;; latex.el ends here
diff --git a/tests/latex/latex-modify-math-test.el b/tests/latex/latex-modify-math-test.el
new file mode 100644
index 00000000..f50d9ae3
--- /dev/null
+++ b/tests/latex/latex-modify-math-test.el
@@ -0,0 +1,203 @@
+;;; latex-modify-math-test.el --- tests for LaTeX-make-inline  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Free Software Foundation, Inc.
+
+;; This file is part of AUCTeX.
+
+;; AUCTeX is free software; you can redistribute it and/or modify it
+;; under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3, or (at your option)
+;; any later version.
+
+;; AUCTeX is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+;; General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with AUCTeX; see the file COPYING.  If not, write to the Free
+;; Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+;; 02110-1301, USA.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'latex)
+
+(defmacro latex-modify-test--with-temp-buffer (contents &rest body)
+  "Create a temporary LaTeX buffer with CONTENTS and execute BODY.
+This macro is used to set up a test environment for `LaTeX-modify-math'."
+  (declare (indent 1) (debug t))
+  `(with-temp-buffer
+     (LaTeX-mode)
+     (insert ,contents)
+     (goto-char (point-min))
+     (cl-letf (((symbol-function 'preview-clearout-at-point) #'ignore))
+       ,@body)))
+
+(ert-deftest LaTeX-modify-math-inline-bracket-period ()
+  "Convert \\=\\[...\\=\\] to $..$ and keep trailing period."
+  (latex-modify-test--with-temp-buffer
+      "We have\n\\[ a+b = c. \\]"
+    (search-forward "b")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "We have $a+b = c$."))))
+
+(ert-deftest LaTeX-modify-math-inline-double-dollar ()
+  "Convert $$..$$ to $..$."
+  (latex-modify-test--with-temp-buffer
+      "$$x!$$"
+    (search-forward "x")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "$x$!"))))
+
+(ert-deftest LaTeX-modify-math-inline-electric-math ()
+  "Respect `TeX-electric-math'."
+  (let ((TeX-electric-math '("\\(" . "\\)")))
+    (latex-modify-test--with-temp-buffer
+        "\\[ x \\]"
+      (search-forward "x")
+      (LaTeX-make-inline)
+      (should (equal (buffer-string) "\\(x\\)")))))
+
+(ert-deftest LaTeX-modify-math-inline-equation-env ()
+  "Convert equation environment, drop \\label, keep comma."
+  (latex-modify-test--with-temp-buffer
+      "Hi.\n\nWe have\n\\begin{equation}\n\\label{l}x+y,\n\\end{equation}\n"
+    (search-forward "x")
+    (let ((TeX-electric-math '("\\(" . "\\)")))
+      (LaTeX-make-inline)
+      (should (equal (buffer-string) "Hi.\n\nWe have \\(x+y\\),\n")))))
+
+(ert-deftest LaTeX-modify-math-inline-noop ()
+  "Call inside inline math leaves buffer unchanged."
+  (latex-modify-test--with-temp-buffer
+      "Already $z$ inline."
+    (search-forward "z")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "Already $z$ inline."))))
+
+(ert-deftest LaTeX-modify-math-inline-paren-to-dollar ()
+  "Convert \\(...\\) to $...$."
+  (latex-modify-test--with-temp-buffer
+      "Text \\(a + b\\) more text."
+    (search-forward "a")
+    (let ((TeX-electric-math nil))
+      (LaTeX-make-inline)
+      (should (equal (buffer-string) "Text $a + b$ more text.")))))
+
+(ert-deftest LaTeX-modify-math-inline-multiline-equation ()
+  "Convert multiline equation environment to inline, removing labels."
+  (latex-modify-test--with-temp-buffer
+      "Before\n\\begin{equation}\n  x + y = z\n  \\label{eq:test}\n\\end{equation}\nAfter"
+    (search-forward "x")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "Before $x + y = z$ After"))))
+
+(ert-deftest LaTeX-modify-math-inline-punctuation-semicolon ()
+  "Move semicolon outside inline math."
+  (latex-modify-test--with-temp-buffer
+      "\\[ x + y; \\]"
+    (search-forward "x")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "$x + y$;"))))
+
+(ert-deftest LaTeX-modify-math-inline-multiple-punctuation ()
+  "Handle multiple punctuation marks."
+  (latex-modify-test--with-temp-buffer
+      "\\[ result?! \\]"
+    (search-forward "result")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "$result$?!"))))
+
+(ert-deftest LaTeX-modify-math-inline-whitespace-preservation ()
+  "Preserve surrounding whitespace appropriately."
+  (latex-modify-test--with-temp-buffer
+      "Text   \\[   a + b   \\]   more."
+    (search-forward "a")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "Text   $a + b$   more."))))
+
+(ert-deftest LaTeX-modify-math-inline-empty-lines ()
+  "Remove empty lines from display math when converting."
+  (latex-modify-test--with-temp-buffer
+      "\\[\n\n  x = y  \n\n\\]"
+    (search-forward "x")
+    (LaTeX-make-inline)
+    (should (equal (buffer-string) "$x = y$"))))
+
+(ert-deftest LaTeX-modify-math-dollar-to-bracket ()
+  "Convert $...$ to \\=\\[...\\=\\]."
+  (latex-modify-test--with-temp-buffer
+      "Text $x + y$ more."
+    (search-forward "+")
+    (LaTeX-modify-math "\\[")
+    (should (equal (buffer-string) "Text\n\\[\n  x + y\n\\]\nmore."))))
+
+(ert-deftest LaTeX-modify-math-paren-to-double-dollar ()
+  "Convert \\(...\\) to $$...$$."
+  (latex-modify-test--with-temp-buffer
+      "Text \\(a = b\\) end."
+    (search-forward "a")
+    (LaTeX-modify-math "$$")
+    (should (equal (buffer-string) "Text\n$$\na = b\n$$\nend."))))
+
+(ert-deftest LaTeX-modify-math-bracket-to-equation ()
+  "Convert \\=\\[...\\=\\] to equation environment."
+  (latex-modify-test--with-temp-buffer
+      "\\[ f(x) = x^2 \\]"
+    (search-forward "f")
+    (LaTeX-modify-math "equation")
+    (should (equal (buffer-string) "\\begin{equation}\n  f(x) = x^2\n\\end{equation}"))))
+
+(ert-deftest LaTeX-modify-math-point-inline-to-display-after-content ()
+  "Point after inline content preserved after display conversion."
+  (latex-modify-test--with-temp-buffer
+      "A $x+y$ B"
+    (search-forward "y")
+    (LaTeX-modify-math "\\[")
+    (should (looking-back "y" (1- (point))))
+    (should (looking-at "\n[[:space:]]*\\\\\\]"))))
+
+(ert-deftest LaTeX-modify-math-point-inline-to-display-before-content ()
+  "Point before inline content preserved after display conversion."
+  (latex-modify-test--with-temp-buffer
+      "A $x+y$ B"
+    (search-forward "$")
+    (LaTeX-modify-math "\\[")
+    (looking-at "x")
+    (should (looking-at "x"))))
+
+(ert-deftest LaTeX-modify-math-point-display-to-inline-after-content ()
+  "Point after display content preserved after inline conversion."
+  (latex-modify-test--with-temp-buffer
+      "\\[\n  x + y\n\\]"
+    (goto-char (point-min))
+    (re-search-forward "y")
+    (LaTeX-make-inline)
+    (should (looking-back "y" (1- (point))))
+    (should (looking-at "\\$"))))
+
+(ert-deftest LaTeX-modify-math-point-display-to-inline-before-content ()
+  "Point before display content preserved after inline conversion."
+  (latex-modify-test--with-temp-buffer
+      "\\[\n  x + y\n\\]"
+    (goto-char (point-min))
+    (re-search-forward "x")
+    (forward-char -1)
+    (LaTeX-make-inline)
+    (should (looking-at "x"))))
+
+(ert-deftest LaTeX-modify-math-point-multiline-roundtrip ()
+  "Point before/after content preserved for round-trip conversion."
+  (latex-modify-test--with-temp-buffer
+      "foo $x+y$ bar"
+    (search-forward "y")
+    (backward-char)
+    (LaTeX-modify-math "\\[")
+    (should (looking-at "y"))
+    (LaTeX-make-inline)
+    (should (looking-at "y"))))
+
+;;; latex-modify-math-test.el ends here
-- 
2.39.3 (Apple Git-145)