Ihor Radchenko wrote:
The most expensive calls are ones to the org-element API.
Not unexpected. This is one of the reasons holding me off from working
on rewriting all the fontification to use org-element API, even though
we will need to do it sooner or later.
Makes sense. And org-list.el seems similar. It would be greatly
simplified by org-element API.
If you have a particular performance standard you think this should
meet, please let me know.
If we are talking about 10-20%, I do not see a bit problem, especially
if the feature is made optional, and disabled by default.
Otherwise, redisplay is one of the sensitive things in Emacs - if it is
slow, it degrades user experience a lot.
I think we're on solid ground here. In realistic benchmarking, scrolling
performance on your test document of all indented list items now
measures within 5% of a document of all headlines.
If we micro-benchmark font-lock-fontify-region in a continuous loop
without redisplay, indented lists take ~1.2x the CPU time that headlines
do. Without invoking redisplay this is pretty artificial, but it should
reasonably estimate the theoretical worst-case.
If you think we need an option to enable this, I can add one. I'm
inclined to think we don't (and that option proliferation is its own
issue), but it's up to you.
One can't depend on match groups in the current version: group numbering
differs based on which way the pattern matches. This is the reason we're
patching this function.
Fair. But then we also need to document the changes in ORG-NEWS.
Done.
An updated patch set is attached.
From 6ca048b3faaaa134236cbfe1841b3da60c5052df Mon Sep 17 00:00:00 2001
From: Jeff Valk <[email protected]>
Date: Sat, 23 May 2026 18:54:31 -0400
Subject: [PATCH 1/3] org-list: Allow list item regexp to isolate bullet
* lisp/org-list.el (org-item-re): Add option to generate regexp so
the bullet is matched as group 1, independently of leading
whitespace. Remove superfluous capture group from default regexp.
Use rx syntax.
(org-item-beginning-re): Revise argument list and docstring to reflect
updated org-item-re.
* testing/lisp/test-org-list.el (test-org-list/match-item): Add test
for identification of list items using both default and bullet
capturing regexps.
---
etc/ORG-NEWS | 9 ++++
lisp/org-list.el | 58 ++++++++++++----------
testing/lisp/test-org-list.el | 90 +++++++++++++++++++++++++++++++++++
3 files changed, 133 insertions(+), 24 deletions(-)
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index c53a42226..4c3990a3e 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -381,6 +381,15 @@ Similarly, if filtered out, ~org-agenda-clock-goto~ would jump close
to one of the invisible entries. Now, it jumps to the closest visible
entry, or displays it in another window if filtered out.
+*** Groups are now defined for =org-item-re=
+
+Previously, regexp groups returned by =org-item-re= and
+=org-item-beginning-re= were not predictable and not documented for
+these functions. Now, calling these functions with the optional
+~capture-bullet~ argument returns a predictable capture group for the
+bullet. Omitting this argument returns a regexp without capture
+groups, and which has faster matching performance.
+
* Version 9.8
** Important announcements and breaking changes
diff --git a/lisp/org-list.el b/lisp/org-list.el
index 8df83663c..7b991afa9 100644
--- a/lisp/org-list.el
+++ b/lisp/org-list.el
@@ -374,34 +374,44 @@ group 4: description tag")
(defvar org--item-re-cache nil
"Results cache for `org-item-re'.")
-(defsubst org-item-re ()
- "Return the correct regular expression for plain lists."
+(defsubst org-item-re (&optional capture-bullet)
+ "Return the correct regular expression for plain lists.
+When CAPTURE-BULLET is non-nil, group 1 matches the bullet without
+leading whitespace. Otherwise, groups are minimized for performance."
(or (plist-get
- (plist-get org--item-re-cache
- org-list-allow-alphabetical)
+ (plist-get
+ (plist-get org--item-re-cache capture-bullet)
+ org-list-allow-alphabetical)
org-plain-list-ordered-item-terminator)
- (let* ((term (cond
- ((eq org-plain-list-ordered-item-terminator t) "[.)]")
- ((= org-plain-list-ordered-item-terminator ?\)) ")")
- ((= org-plain-list-ordered-item-terminator ?.) "\\.")
- (t "[.)]")))
- (alpha (if org-list-allow-alphabetical "\\|[A-Za-z]" ""))
- (re (concat "\\([ \t]*\\([-+]\\|\\(\\([0-9]+" alpha "\\)" term
- "\\)\\)\\|[ \t]+\\*\\)\\([ \t]+\\|$\\)")))
- (setq org--item-re-cache
- (plist-put
- org--item-re-cache
- org-list-allow-alphabetical
- (plist-put
- (plist-get org--item-re-cache
- org-list-allow-alphabetical)
- org-plain-list-ordered-item-terminator
- re)))
+ (let* ((counter `(regexp ,(if org-list-allow-alphabetical
+ "[0-9]+\\|[A-Za-z]"
+ "[0-9]+")))
+ (term (or (car (memq org-plain-list-ordered-item-terminator '(?\) ?.)))
+ '(any ".)")))
+ (bullet `(or (any "-+") (seq ,counter ,term)))
+ (ws '(any " \t"))
+ (re (if capture-bullet
+ (rx-to-string ; captures bullet as group 1
+ `(or (seq (0+ ,ws) (group-n 1 ,bullet (or ,ws eol)))
+ (seq (1+ ,ws) (group-n 1 "*" (or ,ws eol)))))
+ (concat (rx-to-string ; minimizes groups (4 vs 7)
+ `(or (seq (0+ ,ws) ,bullet)
+ (seq (1+ ,ws) "*")))
+ (rx-to-string `(or ,ws eol)))))
+ (c0 org--item-re-cache)
+ (c1 (plist-get c0 capture-bullet))
+ (c2 (plist-get c1 org-list-allow-alphabetical))
+ (c2* (plist-put c2 org-plain-list-ordered-item-terminator re))
+ (c1* (plist-put c1 org-list-allow-alphabetical c2*))
+ (c0* (plist-put c0 capture-bullet c1*)))
+ (setq org--item-re-cache c0*)
re)))
-(defsubst org-item-beginning-re ()
- "Regexp matching the beginning of a plain list item."
- (concat "^" (org-item-re)))
+(defsubst org-item-beginning-re (&optional capture-bullet)
+ "Regexp matching the beginning of a plain list item.
+When CAPTURE-BULLET is non-nil, group 1 matches the bullet without
+leading whitespace. Otherwise, groups are minimized for performance."
+ (concat "^" (org-item-re capture-bullet)))
(defun org-list-at-regexp-after-bullet-p (regexp)
"Is point at a list item with REGEXP after bullet?"
diff --git a/testing/lisp/test-org-list.el b/testing/lisp/test-org-list.el
index 1da621143..e2edeae04 100644
--- a/testing/lisp/test-org-list.el
+++ b/testing/lisp/test-org-list.el
@@ -28,6 +28,96 @@
(require 'org-list)
(require 'org)
+(ert-deftest test-org-list/match-item ()
+ "Test identification of list items.
+With bullet capture, match group 1 should contain the list item bullet.
+This tests `org-item-beginning-re' and by extension `org-item-re'."
+ (cl-flet ((match-group-1 ()
+ (re-search-forward (org-item-beginning-re t)) ; capture bullet
+ (match-string-no-properties 1)))
+ ;; Unordered, unindented
+ (org-test-with-temp-text "- item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "- " (match-group-1))))
+ (org-test-with-temp-text "+ item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "+ " (match-group-1))))
+ ;; Unordered, indented
+ (org-test-with-temp-text " - item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "- " (match-group-1))))
+ (org-test-with-temp-text " + item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "+ " (match-group-1))))
+ (org-test-with-temp-text " * item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "* " (match-group-1))))
+ ;; Ordered, default terminators
+ (let ((org-plain-list-ordered-item-terminator t))
+ ;; unindented
+ (org-test-with-temp-text "1. item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "1. " (match-group-1))))
+ (org-test-with-temp-text "123) item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "123) " (match-group-1))))
+ ;; indented
+ (org-test-with-temp-text " 1. item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "1. " (match-group-1))))
+ (org-test-with-temp-text " 123) item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "123) " (match-group-1))))
+ ;; alpha
+ (let ((org-list-allow-alphabetical t))
+ ;; unindented
+ (org-test-with-temp-text "a) item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "a) " (match-group-1))))
+ (org-test-with-temp-text "Z. item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "Z. " (match-group-1))))
+ ;; indented
+ (org-test-with-temp-text " a) item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "a) " (match-group-1))))
+ (org-test-with-temp-text " Z. item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "Z. " (match-group-1))))))
+ ;; Ordered, specific terminator
+ (let ((org-plain-list-ordered-item-terminator ?.))
+ (org-test-with-temp-text "1. item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "1. " (match-group-1))))
+ (org-test-with-temp-text "1) item"
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1))))
+ (let ((org-plain-list-ordered-item-terminator ?\)))
+ (org-test-with-temp-text "1. item"
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1)))
+ (org-test-with-temp-text "1) item"
+ (should (looking-at (org-item-beginning-re)))
+ (should (equal "1) " (match-group-1)))))
+ ;; Invalid list items
+ (org-test-with-temp-text "* item" ; star without indentation
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1)))
+ (org-test-with-temp-text "1 item" ; no ordinal terminator
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1)))
+ (org-test-with-temp-text "1] item" ; invalid ordinal terminator
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1)))
+ (let ((org-list-allow-alphabetical nil))
+ (org-test-with-temp-text "a) item" ; alpha without option
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1))))
+ (let ((org-list-allow-alphabetical t))
+ (org-test-with-temp-text "ab) item" ; multiple alpha characters
+ (should-not (looking-at (org-item-beginning-re)))
+ (should-error (match-group-1))))))
+
(ert-deftest test-org-list/list-ending ()
"Test if lists end at the right place."
;; With two blank lines.
--
2.55.0
From 9c2ee46e358d3a384653ada84b4a7f8710e4d7ac Mon Sep 17 00:00:00 2001
From: Jeff Valk <[email protected]>
Date: Sun, 7 Jun 2026 22:12:26 -0400
Subject: [PATCH 2/3] org: Improve org-in-block-p
* lisp/org.el (org-in-block-p): Replace regexp-based approach with
element API by renaming and generalizing existing function
'org-in-src-block-p'. This adds flexibility and is more reliable with
nested blocks.
(org-in-src-block-p): Preserve this function name as a thin wrapper
around the improved 'org-in-block-p'.
(org--block-types): Add utility for block name/type arguments.
* lisp/org-element.el (org-element-block-elements): Add new constant.
* lisp/org-list.el: Update arg names in declare-function.
* lisp/org-footnote.el: Update arg names in declare-function.
* testing/lisp/test-org.el (test-org/in-block-p): Add tests for
'org-in-block-p'.
---
lisp/org-element.el | 5 ++
lisp/org-footnote.el | 2 +-
lisp/org-list.el | 2 +-
lisp/org.el | 62 ++++++++++---------
testing/lisp/test-org.el | 125 +++++++++++++++++++++++++++++++++++++++
5 files changed, 167 insertions(+), 29 deletions(-)
diff --git a/lisp/org-element.el b/lisp/org-element.el
index 58c65ad08..32928fdfe 100644
--- a/lisp/org-element.el
+++ b/lisp/org-element.el
@@ -350,6 +350,11 @@ specially in `org-element--object-lex'.")
section table-row)
"List of paragraph-level node types that cannot have affiliated keywords.")
+(defconst org-element-block-elements
+ '( center-block comment-block dynamic-block example-block
+ export-block quote-block special-block src-block verse-block)
+ "List of block element types.")
+
(defconst org-element-affiliated-keywords
'("CAPTION" "DATA" "HEADER" "HEADERS" "LABEL" "NAME" "PLOT" "RESNAME" "RESULT"
"RESULTS" "SOURCE" "SRCNAME" "TBLNAME")
diff --git a/lisp/org-footnote.el b/lisp/org-footnote.el
index 5603adfe6..bc31485f6 100644
--- a/lisp/org-footnote.el
+++ b/lisp/org-footnote.el
@@ -51,7 +51,7 @@
(declare-function org-element-type-p "org-element-ast" (node types))
(declare-function org-end-of-subtree "org" (&optional invisible-ok to-heading element))
(declare-function org-fill-paragraph "org" (&optional justify region))
-(declare-function org-in-block-p "org" (names))
+(declare-function org-in-block-p "org" (&optional types inside element))
(declare-function org-in-verbatim-emphasis "org" ())
(declare-function org-inside-LaTeX-fragment-p "org" (&optional element))
(declare-function org-inside-latex-macro-p "org" ())
diff --git a/lisp/org-list.el b/lisp/org-list.el
index 7b991afa9..57c1e3288 100644
--- a/lisp/org-list.el
+++ b/lisp/org-list.el
@@ -139,7 +139,7 @@
(declare-function org-export-with-backend "ox" (backend data &optional contents info))
(declare-function org-fix-tags-on-the-fly "org" ())
(declare-function org-get-todo-state "org" ())
-(declare-function org-in-block-p "org" (names))
+(declare-function org-in-block-p "org" (&optional types inside element))
(declare-function org-inlinetask-goto-beginning "org-inlinetask" ())
(declare-function org-inlinetask-goto-end "org-inlinetask" ())
(declare-function org-inlinetask-in-task-p "org-inlinetask" ())
diff --git a/lisp/org.el b/lisp/org.el
index 294a5eb52..739578362 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -235,6 +235,7 @@ This regular expression matches these groups:
(declare-function org-toggle-archive-tag "org-archive" (&optional find-done))
(defvar org-agenda-buffer-name)
+(defvar org-element-block-elements)
(defvar org-element-paragraph-separate)
(defvar org-element-cache-map-continue-from)
(defvar org-element--timestamp-regexp)
@@ -19271,17 +19272,41 @@ With prefix arg UNCOMPILED, load the uncompiled versions."
(setq s (replace-match "\\vert" t t s)))
s)
-(defun org-in-src-block-p (&optional inside element)
- "Return t when point is at a source block element.
-When INSIDE is non-nil, return t only when point is between #+BEGIN_SRC
-and #+END_SRC lines.
+(defun org--block-types (names-or-types)
+ "Convert block names to types and filter non-strings to defined block types.
+Name strings that do not map to a defined block type are presumed to
+be names of special blocks and are returned unchanged."
+ (mapcar (lambda (x)
+ (if (stringp x)
+ (let ((type (intern (format "%s-block" (downcase x)))))
+ (or (car (memq type org-element-block-elements)) x))
+ (car (memq x org-element-block-elements))))
+ (ensure-list names-or-types)))
+
+(defun org-in-block-p (&optional types inside element)
+ "Return t when point is in a block element.
+
+Block TYPES may be constrained using a type symbol, a name string,
+or a list including either. Name strings are mapped to type symbols
+for defined block types and compared as a `special-block' :type
+property otherwise.
+
+When INSIDE is non-nil, return t only when point is between #+BEGIN
+and #+END lines.
Note that affiliated keywords and blank lines after are considered a
part of a source block.
When ELEMENT is provided, it is considered to be element at point."
(save-match-data (setq element (or element (org-element-at-point))))
- (when (org-element-type-p element 'src-block)
+ (when-let* ((types (or (org--block-types types) org-element-block-elements))
+ (element (org-element-lineage-map element
+ `(let ((type (org-element-type node)))
+ (or (and (memq type ',types) node)
+ (and (eq type 'special-block)
+ (member (org-element-property :type node) ',types)
+ node)))
+ nil t t)))
(or (not inside)
(not (or (<= (line-beginning-position)
(org-element-post-affiliated element))
@@ -19290,6 +19315,11 @@ When ELEMENT is provided, it is considered to be element at point."
(skip-chars-backward " \t\n\r")
(point))))))))
+(defun org-in-src-block-p (&optional inside element)
+ "Return t when point is at a source block element.
+This simply wraps `org-in-block-p' for type \\='src-block."
+ (org-in-block-p 'src-block inside element))
+
(defun org-context ()
"Return a list of contexts of the current cursor position.
If several contexts apply, all are returned.
@@ -19442,28 +19472,6 @@ position before START-RE (resp. after END-RE)."
;; Return value.
(cons beg end))))))
-(defun org-in-block-p (names)
- "Non-nil when point belongs to a block whose name belongs to NAMES.
-
-NAMES is a list of strings containing names of blocks.
-
-Return first block name matched, or nil. Beware that in case of
-nested blocks, the returned name may not belong to the closest
-block from point."
- (save-match-data
- (catch 'exit
- (let ((case-fold-search t)
- (lim-up (save-excursion (outline-previous-heading)))
- (lim-down (save-excursion (outline-next-heading))))
- (dolist (name names)
- (let ((n (regexp-quote name)))
- (when (org-between-regexps-p
- (concat "^[ \t]*#\\+begin_" n)
- (concat "^[ \t]*#\\+end_" n)
- lim-up lim-down)
- (throw 'exit n)))))
- nil)))
-
;; Defined in org-agenda.el
(defvar org-agenda-restrict)
(defvar org-agenda-restrict-begin)
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index 9e9be9ebc..c5d1fb294 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -6042,6 +6042,131 @@ Text.
(org-next-block 1 "^[ \t]*#\\+BEGIN_QUOTE")
(looking-at "#\\+begin_quote")))))
+(ert-deftest test-org/in-block-p ()
+ "Test `org-in-block-p' specifications."
+ ;; Not in block
+ (should-not
+ (org-test-with-temp-text
+ "<point>\n#+NAME: A\n#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p)))
+ ;; Without inside flag
+ ;;; On affiliated line
+ (should
+ (org-test-with-temp-text
+ "\n<point>#+NAME: A\n#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p)))
+ ;;; On begin line
+ (should
+ (org-test-with-temp-text
+ "\n#+NAME: A\n<point>#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p)))
+ ;;; Inside block
+ (should
+ (org-test-with-temp-text
+ "\n#+NAME: A\n#+BEGIN_EXAMPLE\n<point>B\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p)))
+ ;;; On end line
+ (should
+ (org-test-with-temp-text
+ "\n#+NAME: A\n#+BEGIN_EXAMPLE\nB\n<point>#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p)))
+ ;;; In post blank
+ (should
+ (org-test-with-temp-text
+ "\n#+NAME: A\n#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n<point>\nC\n"
+ (org-in-block-p)))
+ ;; With inside flag
+ ;;; On affiliated line
+ (should-not
+ (org-test-with-temp-text
+ "\n<point>#+NAME: A\n#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p nil t)))
+ ;;; On begin line
+ (should-not
+ (org-test-with-temp-text
+ "\n#+NAME: A\n<point>#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p nil t)))
+ ;;; Inside block
+ (should
+ (org-test-with-temp-text
+ "\n#+NAME: A\n#+BEGIN_EXAMPLE\n<point>B\n#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p nil t)))
+ ;;; On end line
+ (should-not
+ (org-test-with-temp-text
+ "\n#+NAME: A\n#+BEGIN_EXAMPLE\nB\n<point>#+END_EXAMPLE\n\nC\n"
+ (org-in-block-p nil t)))
+ ;;; In post blank
+ (should-not
+ (org-test-with-temp-text
+ "\n#+NAME: A\n#+BEGIN_EXAMPLE\nB\n#+END_EXAMPLE\n<point>\nC\n"
+ (org-in-block-p nil t)))
+ ;; Nested blocks
+ ;;; Block within greater block
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_QUOTE\n#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n#+END_QUOTE\n"
+ (org-in-block-p 'example-block)))
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_QUOTE\n#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n#+END_QUOTE\n"
+ (org-in-block-p 'quote-block)))
+ ;;; Block within lesser block
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_SRC\n#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n#+END_SRC\n"
+ (org-in-block-p 'src-block)))
+ (should-not
+ (org-test-with-temp-text
+ "#+BEGIN_SRC\n#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n#+END_SRC\n"
+ (org-in-block-p 'quote-block)))
+ ;; Type selectors
+ ;;; Symbols
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n"
+ (org-in-block-p 'example-block)))
+ ;; Name strings
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n"
+ (org-in-block-p "example")))
+ ;; Special (named) blocks
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_abcd\n<point>A\n#+END_abcd\n"
+ (org-in-block-p "abcd")))
+ (should-not
+ (org-test-with-temp-text
+ "#+BEGIN_abcd\n<point>A\n#+END_abcd\n"
+ (org-in-block-p 'abcd-block)))
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_abcd\n<point>A\n#+END_abcd\n"
+ (org-in-block-p 'special-block)))
+ ;; Dynamic blocks
+ (should-not
+ (org-test-with-temp-text
+ "#+BEGIN: abcd\n<point>A\n#+END\n"
+ (org-in-block-p "abcd")))
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN: abcd\n<point>A\n#+END\n"
+ (org-in-block-p 'dynamic-block)))
+ ;;; Multiple type selectors
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_EXAMPLE\n<point>A\n#+END_EXAMPLE\n"
+ (org-in-block-p '(example-block "src" "abcd"))))
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_SRC\n<point>A\n#+END_SRC\n"
+ (org-in-block-p '(example-block "src" "abcd"))))
+ (should
+ (org-test-with-temp-text
+ "#+BEGIN_abcd\n<point>A\n#+END_abcd\n"
+ (org-in-block-p '(example-block "src" "abcd")))))
+
;;; Outline structure
--
2.55.0
From 379d7c237a7c080034167d9d9fd3a3016b202e34 Mon Sep 17 00:00:00 2001
From: Jeff Valk <[email protected]>
Date: Mon, 8 Jun 2026 21:16:45 -0400
Subject: [PATCH 3/3] Add faces for structural syntax elements
This adds an identifying face and fontification for syntax elements
that convey document outline structure: headline stars, list bullets,
list indentation. Structure faces inherit from a newly introduced
common ancestor. The new faces are defined without any properties,
which adds useful semantic information but reserves any visual
customization to the user.
Fontification of indentation is context-sensitive: it is applied only
within a plain list. This is straightforward with one caveat: a
buffer change might either create or end an outermost plain list
around indented content. This would change the syntactic significance
of indentation, and thus requires the fontification region to be
extended.
* lisp/org-faces.el (org-structure): Add new face.
(org-headline-stars, org-list-bullet, org-list-indent): Add new faces,
inheriting from org-structure.
(org-hide): Modify face to inherit from org-structure. This face is
used for structural syntax, even if made invisible. Importantly, the
org-indent face inherits from org-hide.
* lisp/org-list.el (org-in-list-context-p,
org-list--forbidden-block-types): Add function to determine whether
plain list indentation syntax applies, and a constant to support this.
* lisp/org.el (org-set-font-lock-defaults): Apply org-list-bullet face.
(org-get-level-face): Apply org-headline-stars face.
(org-fontify-leading-space): Add new function.
(org-fontify-extend-region, org-fontify-extend-space,
org-fontify-extend-block): Delegate region extension to helper
functions for readability. Extend region when change may create or
end a plain list item having indented child content other than nested
list items. Absorb the end-of-line region extension fix into
org-fontify-extend-space where it's logically compatible.
* etc/ORG-NEWS: Describe the new outline structure faces.
---
etc/ORG-NEWS | 18 ++++++++++++
lisp/org-faces.el | 27 +++++++++++++++++-
lisp/org-list.el | 20 +++++++++++++
lisp/org.el | 71 +++++++++++++++++++++++++++++++++++++++++------
4 files changed, 126 insertions(+), 10 deletions(-)
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 4c3990a3e..89d5ef7b3 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -172,6 +172,24 @@ Presentations written with Beamer and ltx-talk are mostly portable,
except that Beamer theme information is ignored when using ltx-talk
and some environments listed in the manual are not supported.
+*** New faces and fontification for document outline structure
+There are now identifying faces applied to syntax elements that convey
+document outline structure: headline stars (=org-headline-stars=),
+list bullets (=org-list-bullet=), and indentation within plain lists
+(=org-list-indent=). These faces, as well as existing faces
+=org-hide= and =org-indent= (via =org-hide=), now inherit from a newly
+introduced common ancestor, =org-structure=.
+
+This enables more flexible control over how outline structure is
+rendered. As a motivating example: customizing =org-structure= to
+use a fixed-pitch font will ensure consistent alignment of outline
+elements while using a variable-pitch face for default text.
+
+A note to theme authors: if your theme customizes =org-hide= or
+=org-indent=, it is /strongly/ recommended that you ensure these
+inherit from =org-structure=. The same is true for the newly
+introduced faces noted above.
+
** New and changed options
# Changes dealing with changing default values of customizations,
diff --git a/lisp/org-faces.el b/lisp/org-faces.el
index b0f5ef9d7..3b40abc2c 100644
--- a/lisp/org-faces.el
+++ b/lisp/org-faces.el
@@ -41,8 +41,33 @@
"Face used for default text."
:group 'org-faces)
+(defface org-structure
+ '((t ()))
+ "Face used for syntax that defines outline structure."
+ :group 'org-faces
+ :package-version '(Org . "10.0"))
+
+(defface org-headline-stars
+ '((t (:inherit org-structure)))
+ "Face used for stars in headlines."
+ :group 'org-faces
+ :package-version '(Org . "10.0"))
+
+(defface org-list-bullet
+ '((t (:inherit org-structure)))
+ "Face used for bullets or numerals in plain lists."
+ :group 'org-faces
+ :package-version '(Org . "10.0"))
+
+(defface org-list-indent
+ '((t (:inherit org-structure)))
+ "Face used for significant leading whitespace in plain lists."
+ :group 'org-faces
+ :package-version '(Org . "10.0"))
+
(defface org-hide
- '((((background light)) (:foreground "white"))
+ '((default :inherit org-structure)
+ (((background light)) (:foreground "white"))
(((background dark)) (:foreground "black")))
"Face used to hide leading stars in headlines.
The foreground color of this face should be equal to the background
diff --git a/lisp/org-list.el b/lisp/org-list.el
index 57c1e3288..9d5464060 100644
--- a/lisp/org-list.el
+++ b/lisp/org-list.el
@@ -113,6 +113,7 @@
(declare-function org-element-context "org-element" (&optional element))
(declare-function org-element-interpret-data "org-element" (data))
(declare-function org-element-lineage "org-element-ast" (blob &optional types with-self))
+(declare-function org-element-lineage-map "org-element-ast" (datum fun &optional types with-self first-match))
(declare-function org-element-macro-interpreter "org-element" (macro ##))
(declare-function org-element-map "org-element" (data types fun &optional info first-match no-recursion with-affiliated no-undefer))
(declare-function org-element-normalize-string "org-element" (s &optional keep-newlines))
@@ -355,6 +356,10 @@ clearly distinguish sub-items in a list."
"Names of blocks where lists are not allowed.
Names must be in lower case.")
+(defconst org-list--forbidden-block-types
+ '(example-block export-block src-block verse-block)
+ "Types of blocks where lists are not allowed.")
+
;;; Predicates and regexps
@@ -431,6 +436,21 @@ leading whitespace. Otherwise, groups are minimized for performance."
"Is point in a context where lists are allowed?"
(not (org-in-block-p org-list-forbidden-blocks)))
+(defun org-in-list-context-p (element)
+ "Return t if in a plain list and not inside a list-forbidding block.
+This identifies whether indentation is meaningful syntax for ELEMENT."
+ ;; This function should maintain performance suitable for frequently
+ ;; repeated calls by fontify machinery. Accordingly, it minimizes
+ ;; traversal, uses car to get element type, and calls org-in-block-p
+ ;; only if needed after a faster type check.
+ (eq 'plain-list
+ (org-element-lineage-map element
+ `(let ((type (car node)))
+ (or (and (memq type '(item plain-list)) 'plain-list)
+ (and (memq type org-list--forbidden-block-types)
+ (org-in-block-p type t node))))
+ nil t t)))
+
(defun org-in-item-p ()
"Return item beginning position when in a plain list, nil otherwise."
(save-excursion
diff --git a/lisp/org.el b/lisp/org.el
index 739578362..f7ef89a04 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -5831,12 +5831,44 @@ by a #."
(add-text-properties closing-start end '(invisible t)))
t)))))
-(defun org-fontify-extend-region (beg end _old-len)
- (let ((end (if (progn (goto-char end) (looking-at-p "^[*#]"))
- (min (point-max) (1+ end))
- ;; See `font-lock-extend-jit-lock-region-after-change' and bug#68849.
- (min (point-max) (1+ end))))
- (begin-re "\\(\\\\\\[\\|\\(#\\+begin_\\|\\\\begin{\\)\\S-+\\)")
+(defun org-fontify-leading-space (limit)
+ "Fontify leading whitespace in plain lists."
+ (when (re-search-forward "^[ \t]+" limit t)
+ (let ((beg (match-beginning 0))
+ (end (match-end 0))
+ (elem (save-match-data (org-element-at-point))))
+ ;; Indentation is syntactically meaningful within plain lists
+ ;; including for paragraphs, etc that lack identifying syntax.
+ ;; Fontify, omitting blocks in which lists are not allowed.
+ (when (org-in-list-context-p elem)
+ (add-face-text-property beg end 'org-list-indent)))
+ t))
+
+(defun org-fontify-extend-space (beg end _old-len)
+ "Extend the region to refontify for plain list indentation and line endings.
+
+If an outermost plain list is either created or ended by a change, it
+alters whether indentation is significant for child contents. To
+accommodate, extend the region to include any indented lines between
+the end of the change and where either the next list item begins or
+an outermost list would necessarily end (a non-indented line or the
+end of buffer).
+
+Always extend the region forward to the next line. Even when not
+needed for plain list indentation, this ensures that end of line
+fontification is correct for faces with :extend t. This digests edge
+case fixes from d74a82448 and 1abff3859.
+See `font-lock-extend-jit-lock-region-after-change' and bug#68849."
+ (goto-char end)
+ (forward-line)
+ (let ((re (concat "\\S-\\|\\'\\|" (org-item-re))))
+ (while (not (looking-at-p re))
+ (forward-line))
+ (cons beg (point))))
+
+(defun org-fontify-extend-block (beg end _old-len)
+ "Extend the region to refontify when block begin/end lines change."
+ (let ((begin-re "\\(\\\\\\[\\|\\(#\\+begin_\\|\\\\begin{\\)\\S-+\\)")
(end-re "\\(\\\\\\]\\|\\(#\\+end_\\|\\\\end{\\)\\S-+\\)")
(extend
(lambda (r1 r2 dir)
@@ -5855,6 +5887,21 @@ by a #."
(cons beg (or (funcall extend "end" "]" 1) end)))
(t (cons beg end))))))
+(defun org-fontify-extend-region (&rest args)
+ "Return the region to refontify after a change.
+
+This dispatches to case-specific functions that return expanded
+bounds if needed and the default bounds otherwise. Each function
+executes independently of the others. The returned region to
+refontify is the aggregate of these.
+
+See `font-lock-extend-after-change-region-function'"
+ (let* ((fns '(org-fontify-extend-space
+ org-fontify-extend-block))
+ (bounds (mapcar (lambda (f) (apply f args)) fns)))
+ (cons (apply #'min (mapcar #'car bounds))
+ (apply #'max (mapcar #'cdr bounds)))))
+
(defun org-activate-footnote-links (limit)
"Add text properties for footnotes."
(let ((fn (org-footnote-next-reference-or-definition limit)))
@@ -6103,6 +6150,9 @@ needs to be inserted at a specific position in the font-lock sequence.")
(1 (org-get-level-face 1))
(2 (org-get-level-face 2))
(3 (org-get-level-face 3)))
+ ;; Plain lists
+ `(,(org-item-beginning-re t)
+ (1 'org-list-bullet))
;; Table lines
'("^[ \t]*\\(\\(|\\|\\+-[-+]\\).*\\S-\\)\n?"
(0 'org-table-row t)
@@ -6211,6 +6261,8 @@ needs to be inserted at a specific position in the font-lock sequence.")
(org-cite-try-load-processor org-cite-activate-processor))
;; prepends faces
'(org-cite-activate))
+ ;; Leading whitespace
+ '(org-fontify-leading-space)
;; COMMENT
;; Apply this last, after all the markup is highlighted, so
;; that even "bright" markup will become dim.
@@ -6319,10 +6371,11 @@ needs to be inserted at a specific position in the font-lock sequence.")
(org-l (if org-odd-levels-only (1+ (/ org-l0 2)) org-l0))
(org-f (if org-cycle-level-faces
(nth (% (1- org-l) org-n-level-faces) org-level-faces)
- (nth (1- (min org-l org-n-level-faces)) org-level-faces))))
+ (nth (1- (min org-l org-n-level-faces)) org-level-faces)))
+ (org-f-stars (list 'org-headline-stars org-f)))
(cond
- ((eq n 1) (if org-hide-leading-stars 'org-hide org-f))
- ((eq n 2) org-f)
+ ((eq n 1) (if org-hide-leading-stars 'org-hide org-f-stars))
+ ((eq n 2) org-f-stars)
(t (unless org-level-color-stars-only org-f)))))
(defun org-face-from-face-or-color (context inherit face-or-color)
--
2.55.0