Subject: Re: bug#78586: TeX-make-inline

From: "Paul D. Nelson" <[email-masked]>
Date: Wed, 04 Jun 2025 12:22:19 +0200

Hi Ikumi,

> +        (forward-char (length open))
> +        (save-excursion (join-line))
> +        (forward-char (- (length open)))))
>
> Why do you go forth and back here? What's wrong with a bare `join-line'
> without two `forward-char's?

Suppose the buffer contains:

--8<---------------cut here---------------start------------->8---
We have
\begin{equation*}
  x + y = z.
\end{equation*}
--8<---------------cut here---------------end--------------->8---

With point on line 2, join-line yields a buffer with first line:

--8<---------------cut here---------------start------------->8---
We have \begin{equation*}
--8<---------------cut here---------------end--------------->8---

The motivation for the slightly convoluted code is that, when point is
at beginning of line, (save-excursion (join-line)) places point just
before " \begin" (rather than just before "\begin", as one might have
expected).  On the other hand, it behaves in the expected way if point
occurs later in the line (e.g., after "}").  Rather than trying to
understand exactly why join-line behaves this way (which seems like a
potentially non-robust feature that could change in the future), it
seemed simpler just to do the little dance that you see in the code.
I've added a comment to the code summarizing the above.

> We have to keep
> (setq pos nil)
> after `set-marker', which doesn't change the value of `pos' itself.

Thanks, good catch.

> As far as I can see, the proposed feature doesn't support docTeX mode. I
> don't think that is a practical problem at all, but addition of FIXME
> comment and breif mention about it in the documentation would be nice.

I'll trust you on this one -- I'm regrettably ignorant of TeX outside a
limited practical subset of LaTeX.  I've added some FIXME comments,
hopefully as intended.

There remains the question of where in the manual to document
LaTeX-modify-math and whether to illustrate it via examples like in my
earlier email.

Thanks, best,

Paul

>From af62d287b5f45fedbc963ad4231ef736045b38b4 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--strip-labels, LaTeX--modify-math-1)
(LaTeX--closing, LaTeX--math-environment-list): 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                       |  11 ++
 latex.el                              | 207 ++++++++++++++++++++++++++
 tests/latex/latex-modify-math-test.el | 203 +++++++++++++++++++++++++
 3 files changed, 421 insertions(+)
 create mode 100644 tests/latex/latex-modify-math-test.el

diff --git a/doc/auctex.texi b/doc/auctex.texi
index 0b486c8c..d62f83b7 100644
--- a/doc/auctex.texi
+++ b/doc/auctex.texi
@@ -484,6 +484,17 @@ 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.  (It may not work
+correctly in docTeX.)
+@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..29ddb9a8 100644
--- a/latex.el
+++ b/latex.el
@@ -9551,6 +9551,213 @@ 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--strip-labels ()
+  "Remove label commands between point and end of buffer."
+  (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))
+             "\\)")))
+    (save-excursion
+      (while (re-search-forward re nil t)
+        (replace-match "")))))
+
+(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 (save-excursion
+              (skip-chars-backward "[:blank:]")
+              (and
+               (bolp) (not (bobp))
+               (progn
+                 (forward-char -1)
+                 (skip-chars-backward "[:blank:]")
+                 (not (bolp)))))
+        ;; The following dance gets around the slightly counterintuitive
+        ;; behavior of (save-excursion (join-line)) with point at bol.
+        (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 (save-excursion
+                (skip-chars-backward "[:blank:]")
+                (and
+                 (bolp) (not (bobp))))
+        (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")
+                   (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))
+        (LaTeX--strip-labels)
+        ;; 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 (= (point) pos)
+          (set-marker-insertion-type pos (not 'advance)))
+        (when converting-to-inline
+          (skip-chars-backward ".,;:!?"))
+        (insert new-close)
+        ;; Indent, including one line past the modified region.
+        (widen)
+        (end-of-line 2)
+        (indent-region start (point))))))
+
+(defun LaTeX--math-environment-list ()
+  "Return list of defined math environments.
+This combines the env-on entries from `texmathp' and any user additions."
+  (texmathp-compile)
+  (mapcar #'car
+          (cl-remove-if-not
+           (lambda (entry)
+             (eq (nth 1 entry) 'env-on))
+           texmathp-tex-commands1)))
+
+(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 (member type (LaTeX--math-environment-list))
+         (error "Invalid or unsupported opening delimiter: %s" type))
+       (concat TeX-esc "end" TeX-grop type TeX-grcl))))
+
+(defun LaTeX-modify-math (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."
+  ;; FIXME: this function may not work correctly in docTeX
+  (interactive
+   (let* ((type (progn (texmathp) (car texmathp-why)))
+          (tbl (append '("$" "\\(" "$$" "\\[")
+                       (LaTeX--math-environment-list))))
+     (barf-if-buffer-read-only)
+     (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.
+          (delete-char (length open))
+          (push-mark (save-excursion
+                       (search-forward close)
+                       (delete-region (match-beginning 0) (match-end 0))
+                       (when (= (point) pos)
+                         (set-marker pos nil)
+                         (setq pos nil))
+                       (when (member type '("$" "\\("))
+                         (skip-chars-forward ".,;:!?"))
+                       (point)))
+          (activate-mark)
+          (LaTeX-insert-environment new-type)))
+      (when pos
+        (goto-char pos)
+        (set-marker pos nil)))))
+
+(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 \"$...$\".  Leave any
+trailing punctuation outside the math delimiters."
+  ;; FIXME: this function may not work correctly in docTeX
+  (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)