bug#81054: [PATCH] Add comment-prefixed outline heading recognition

Paul Hsin-ti McClelland via "Bug reports for GNU Emacs, the Swiss army knife of text editors" <[email protected]> Sat, 01 Aug 2026 22:23:45 +0000
Newsgroups gmane.emacs.bugs
Message-ID <[email protected]>
Apologies for the delay---had a move and board exams to take.  Resuming
work on this patch.

> The patches bug#80887 and the defcustom are pushed now.  So please
> rebase this patch against master (probably after making more changes).
 
Thanks.  Now that my copyright assignment has gone through, I've rebased
against current master and made the changes below.  The revised patch is
attached.
 
> We need to set 'outline-comment-regexp' explicitly in Elisp mode
> (actually in 'lisp-mode-variables') to ";;; \([*]+\)".
 
Done.  lisp-mode-variables now sets
 
  (setq-local outline-comment-regexp ";;; \\([*]+\\)")
 
so the default construction from comment-start no longer has to guess
the Lisp convention.  The default heuristic stays conservative (one
extra comment character for single-character starters); any mode that
wants a different convention just sets outline-comment-regexp itself.
 
> The value 1000 is too specific to 'lisp-outline-level'.  We need to
> try reusing the existing functions from original buffer-local values
> of 'outline-level' and 'outline-regexp' stored in an internal
> variable.
 
Sounds good---the 1000 is gone.  Setup now saves the pre-existing
outline-regexp and outline-level in internal buffer-local variables, and
the overridden level function defers to them when the comment part
doesn't match:
 
  (defun outline--comment-level () (if (and outline--comment-regexp
  (looking-at outline--comment-regexp) (match-beginning 1)) (-
  (match-end 1) (match-beginning 1)) (when outline--saved-outline-regexp
  (looking-at outline--saved-outline-regexp)) (funcall (or
  outline--saved-outline-level #'outline-level))))
 
This is the "call looking-at again, then fall back to the saved
outline-level" suggestion.  One detail I added: before calling the saved
function I re-run looking-at with the saved outline-regexp, so the saved
function (e.g., lisp-outline-level) sees the match data it expects rather
than the data left by the failed comment looking-at.  For Lisp a bare
"(" then gets its level straight from lisp-outline-level (which itself
returns 1000).  So there is nothing Lisp-specific in outline.el.
 
> Is it possible to implement a fallback for the case when there are no
> stars found in the matched comment.  [...]  Do you think this is
> feasible?
 
Yes.  This patch implements that groundwork now (as the 't' behaviour);
we can add a 'combined value as a follow-up.  This 'combined value only
needs to (a) build the alternation for outline-regexp without an extra
capture group, as you described, and (b) add the offset to the
fall-through result.
 
One thing to settle for 'combined: your example bumps the original level
by 1000 (ruby class -> 1001).  For Lisp the saved function already
returns 1000 for "(", so a flat +1000 would give 2000 there.  So the
offset probably wants to be a single large constant applied only on the
fall-through branch, rather than literally added on top of whatever the
mode's level function returns.  If we do end up implementing 'combined, 
we can sort this out.
 
> The value " \([*]+\)" could be moved to the default value of a new
> variable that the users might want to set buffer-locally.
 
Done, as the defcustom outline-comment-regexp-suffix (default "
\\([*]+\\)").  Happy to rename it if you'd prefer something else.
 
> Probably the option 'outline-use-comment-regexp' should support three
> values: 1. nil ...  2. t ...  3. ... 'combined ... could be added
> later ...
 
The patch implements nil and t.  I made :type a choice rather than a
boolean so that adding 'combined later is a one-line change and doesn't
alter the customization type:
 
  :type '(choice (const :tag "Don't recognize comment headings" nil)
  (const :tag "Recognize comment headings" t))
 
Two additional items---let me know if you agree:
 
1. Since 29.1, several modes set outline-search-function, which takes
precedence over outline-regexp.  If setup only touched outline-regexp,
the comment headings would never be found in those modes, defeating the
"works in any mode with a comment-start" goal.  So setup also does
 
  (setq-local outline-search-function nil)
 
(saving the old value), forcing the regexp path onto the comment regexp.
That is the right behaviour for the 't' (replace) case, and stays
correct for 'combined.  Let me know if you'd rather handle it another
way.
 
2. The original patch only set things up, so I added restoration of
outline-regexp, outline-level and outline-search-function from the saved
variables when outline-minor-mode is disabled, so toggling the mode off
doesn't leave the comment regexp behind.
 
I also gave outline-comment-regexp a safe-local-variable property
(stringp), to match outline-regexp and outline-heading-end-regexp.

When convenient, take a look at the patch and let me know what you think.
 
PHM
0001-Recognize-comment-prefixed-headings-in-outlines.patch (text/x-patch, 11.3 KB)
From 747202fd5d5336248d084a17aed98e73a50403a2 Mon Sep 17 00:00:00 2001
From: "Paul H. McClelland" <[email protected]>
Date: Sat, 1 Aug 2026 18:06:29 -0400
Subject: [PATCH] Recognize comment-prefixed headings in outlines

When the new user option 'outline-use-comment-regexp' is non-nil, enabling
'outline-minor-mode' configures 'outline-regexp', 'outline-level', and
'outline-search-function' to recognize outline headings prefixed by a comment,
such as ";; *" and ";; **".  The pattern comes from the buffer-local
'outline-comment-regexp' when set, and otherwise from a default built from
'comment-start' and the new option 'outline-comment-regexp-suffix'.  The
heading level is the length of the regexp's group 1.  The previous values are
saved and restored when the mode is disabled.

* lisp/outline.el (outline-comment-regexp): New buffer-local variable.
  (outline-comment-regexp-suffix, outline-use-comment-regexp): New user options.
  (outline--saved-outline-regexp, outline--saved-outline-level)
  (outline--saved-outline-search-function, outline--comment-regexp): New
  internal variables.
  (outline--comment-level, outline--default-comment-regexp)
  (outline--setup-from-comment-regexp): New functions.
  (outline-minor-mode): Set up comment-heading recognition on activation when
  'outline-use-comment-regexp' is non-nil; restore the saved values on
  deactivation.
* lisp/emacs-lisp/lisp-mode.el (lisp-mode-variables): Set
  'outline-comment-regexp' to recognize ";;; *" headings.
* etc/NEWS: Announce the new options.
---
 etc/NEWS                     |  16 ++++
 lisp/emacs-lisp/lisp-mode.el |   1 +
 lisp/outline.el              | 141 +++++++++++++++++++++++++++++++++++
 3 files changed, 158 insertions(+)

diff --git a/etc/NEWS b/etc/NEWS
index 23b97971105..a6a6758474e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -196,6 +196,22 @@ with named choices for the predefined search functions
 ('outline-search-from-regexp' and 'outline-search-level')
 as well as the default nil and arbitrary user functions.
 
+---
+*** New options to recognize comment-prefixed outline headings.
+When the user option 'outline-use-comment-regexp' is non-nil, enabling
+'outline-minor-mode' configures heading recognition so that outline
+headings prefixed by a comment are recognized, for example ";; *",
+";; **", and so on in a buffer whose comment syntax is ";".  The heading
+level is the number of stars.
+ 
+The heading pattern is taken from the buffer-local variable
+'outline-comment-regexp' when that is set; major modes and users can set
+it to customize recognition per mode.  Otherwise a pattern is built from
+'comment-start' and the new option 'outline-comment-regexp-suffix'.
+ 
+In Emacs Lisp mode and the other Lisp modes, 'outline-comment-regexp' is
+preset to recognize ";;; *", ";;; **", and so on.
+ 
 ** Newsticker
 
 ---
diff --git a/lisp/emacs-lisp/lisp-mode.el b/lisp/emacs-lisp/lisp-mode.el
index 1ea1916ff4a..bb2a513e084 100644
--- a/lisp/emacs-lisp/lisp-mode.el
+++ b/lisp/emacs-lisp/lisp-mode.el
@@ -699,6 +699,7 @@ font-lock keywords will not be case sensitive."
   (setq-local outline-regexp (concat ";;;;* [^ \t\n]\\|(\\|\\("
                                      lisp-mode-autoload-regexp
                                      "\\)"))
+  (setq-local outline-comment-regexp ";;; \\([*]+\\)")
   (setq-local outline-level 'lisp-outline-level)
   (setq-local add-log-current-defun-function #'lisp-current-defun-name)
   (setq-local comment-start ";")
diff --git a/lisp/outline.el b/lisp/outline.el
index 4b4f3c2d520..a061a000d21 100644
--- a/lisp/outline.el
+++ b/lisp/outline.el
@@ -61,6 +61,73 @@ The recommended way to set this is with a `Local Variables:' list
 in the file it applies to.")
 ;;;###autoload(put 'outline-heading-end-regexp 'safe-local-variable 'stringp)
 
+(defvar-local outline-comment-regexp nil
+  "If non-nil, regexp matching comment-prefixed outline headings.
+This has an effect only when `outline-use-comment-regexp' is non-nil.
+In that case, enabling `outline-minor-mode' derives `outline-regexp'
+and the variable `outline-level' from it.
+
+Group 1 of the regexp must match the heading-level characters; the
+heading level is the length of that group's match.  For example, the
+value
+
+  \";;; \\([*]+\\)\"
+
+recognizes the headings \";;; *\", \";;; **\", and so on, using the number
+of stars as the level.
+
+When this variable is nil, a default is constructed from `comment-start'
+and `outline-comment-regexp-suffix'.  Major modes can set this variable
+to provide a mode-specific heading syntax.")
+;;;###autoload(put 'outline-comment-regexp 'safe-local-variable 'stringp)
+
+(defcustom outline-comment-regexp-suffix " \\([*]+\\)"
+  "Regexp matching a heading marker that follows the comment prefix.
+This is appended to the comment prefix to construct a default value for
+`outline-regexp' when `outline-use-comment-regexp' is non-nil and
+`outline-comment-regexp' is nil.  Group 1 must match the heading-level
+characters; the heading level is the length of that group's match.
+
+The default value matches a space followed by one or more stars, so that
+headings look like \";; *\", \";; **\", and so on.  Set it buffer-locally
+to use a different separator or level character."
+  :type 'regexp
+  :group 'outlines
+  :version "32.1")
+
+(defcustom outline-use-comment-regexp nil
+  "If non-nil, recognize comment-prefixed headings in `outline-minor-mode'.
+When non-nil, enabling `outline-minor-mode' configures `outline-regexp'
+and the variable `outline-level' to recognize outline headings that are
+prefixed by a comment.  The heading pattern is taken from
+`outline-comment-regexp' when that variable is set buffer-locally, and
+otherwise from a default constructed from `comment-start' and
+`outline-comment-regexp-suffix'."
+  :type '(choice (const :tag "Don't recognize comment headings" nil)
+                 (const :tag "Recognize comment headings" t))
+  :group 'outlines
+  :version "32.1")
+
+(defvar-local outline--saved-outline-regexp nil
+  "Value of `outline-regexp' saved before comment-heading setup.
+Restored when `outline-minor-mode' is disabled.  See
+`outline--setup-from-comment-regexp' and `outline--comment-level'.")
+
+(defvar-local outline--saved-outline-level nil
+  "Value of the variable `outline-level' saved before comment-heading setup.
+Restored when `outline-minor-mode' is disabled.  See
+`outline--setup-from-comment-regexp' and `outline--comment-level'.")
+
+(defvar-local outline--comment-regexp nil
+  "Regexp whose group 1 gives the level of a comment-prefixed heading.
+Set by `outline--setup-from-comment-regexp' and used by
+`outline--comment-level'.")
+
+(defvar-local outline--saved-outline-search-function nil
+  "Value of `outline-search-function' saved before comment-heading setup.
+Restored when `outline-minor-mode' is disabled.  See
+`outline--setup-from-comment-regexp'.")
+
 (defcustom outline-search-function nil
   "If non-nil, the function to search the next outline heading.
 When nil, headings are found by searching for `outline-regexp'.
@@ -619,7 +686,17 @@ See the command `outline-mode' for more information on this mode."
         (setq-local line-move-ignore-invisible t)
 	;; Cause use of ellipses for invisible text.
 	(add-to-invisibility-spec '(outline . t))
+        (when outline-use-comment-regexp
+          (outline--setup-from-comment-regexp))
 	(outline-apply-default-state))
+    (when outline--saved-outline-level
+      (setq-local outline-level outline--saved-outline-level
+                  outline-regexp outline--saved-outline-regexp
+                  outline-search-function outline--saved-outline-search-function)
+      (setq outline--saved-outline-level nil
+            outline--saved-outline-regexp nil
+            outline--saved-outline-search-function nil
+            outline--comment-regexp nil))
     (jit-lock-unregister #'outline--fix-buttons)
     (remove-hook 'revert-buffer-restore-functions
                  #'outline-revert-buffer-restore-visibility t)
@@ -1507,6 +1584,70 @@ If there is no such heading, return nil."
 	  nil
         (point)))))
 
+
+;;; Setup from comment-prefixed regexp
+
+(defun outline--comment-level ()
+  "Return the level of a comment-prefixed outline heading.
+Point must be at the beginning of a heading line.  When the line matches
+`outline--comment-regexp' with a non-empty group 1 (the heading-level
+characters), return the length of that group's match.  Otherwise defer to
+the function `outline-level' that was in effect before comment-heading
+setup, saved in `outline--saved-outline-level', after re-establishing its
+expected match data from `outline--saved-outline-regexp'.
+
+This is installed into the variable `outline-level' by
+`outline--setup-from-comment-regexp'."
+  (if (and outline--comment-regexp
+           (looking-at outline--comment-regexp)
+           (match-beginning 1))
+      (- (match-end 1) (match-beginning 1))
+    (when outline--saved-outline-regexp
+      (looking-at outline--saved-outline-regexp))
+    (funcall (or outline--saved-outline-level #'outline-level))))
+
+(defun outline--default-comment-regexp ()
+  "Construct a default comment-heading regexp from `comment-start'.
+The result matches `comment-start' (with the variable `comment-add'
+applied for single-character comment starters) followed by
+`outline-comment-regexp-suffix'.  For example, the result is
+\";; \\([*]+\\)\" in a buffer whose `comment-start' is \";\", and
+\"// \\([*]+\\)\" when it is \"//\".
+
+Return nil when `comment-start' is nil."
+  (when comment-start
+    (let* ((cs (string-trim-right comment-start))
+           (prefix (if (= (length cs) 1)
+                       (make-string (1+ (or comment-add 0)) (aref cs 0))
+                     cs)))
+      (concat (regexp-quote prefix) outline-comment-regexp-suffix))))
+
+(defun outline--setup-from-comment-regexp ()
+  "Configure outline heading recognition for comment-prefixed headings.
+Called from `outline-minor-mode' when `outline-use-comment-regexp' is
+non-nil.  Use `outline-comment-regexp' when it is set buffer-locally, and
+otherwise a default from `outline--default-comment-regexp'.  Do nothing
+when no comment-heading regexp is available.
+
+Set `outline-regexp' and the variable `outline-level' from that regexp,
+and clear `outline-search-function' (which many modes set, and which
+would otherwise override `outline-regexp').  Save the previous values in
+`outline--saved-outline-regexp', `outline--saved-outline-level', and
+`outline--saved-outline-search-function' so that `outline--comment-level'
+can defer to them and so that disabling `outline-minor-mode' can restore
+them."
+  (let ((regexp (or outline-comment-regexp
+                    (outline--default-comment-regexp))))
+    (when regexp
+      (unless outline--saved-outline-level
+        (setq outline--saved-outline-regexp outline-regexp
+              outline--saved-outline-level outline-level
+              outline--saved-outline-search-function outline-search-function))
+      (setq outline--comment-regexp regexp)
+      (setq-local outline-regexp regexp)
+      (setq-local outline-level #'outline--comment-level)
+      (setq-local outline-search-function nil))))
+
 
 ;;; Search text-property for outline headings
 
-- 
2.55.0