branch: elpa/adoc-mode
commit d1a03364d9dfd388a9789a417b623ddec15dc43a
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
Stop heading navigation and imenu tripping over code blocks
The heading-navigation commands (C-c C-n and friends) and the imenu
index found section titles purely by regexp, so any `==`-style line
inside a listing, source, literal, example, sidebar, quote, or open
block was treated as a heading. A code line followed by `----` was even
worse: it looks exactly like a two-line title underline, so navigation
would land right inside a source block.
Validate each candidate against what font-lock actually highlighted: a
line counts as a heading only when it carries title highlighting. For
one-line titles the leading `==` keeps `adoc-meta-hide-face` even when
inline markup repaints the title text (so a title that is entirely a
link or image macro still works), while a surrounding block repaints the
delimiter and the line is skipped. Two-line titles fall back to checking
for a title face on the text.
This also makes navigation and imenu honour adoc-enable-two-line-title,
which is nil by default, so deprecated setext titles are now ignored
unless opted in - matching their fontification and removing the main
source of the code-block confusion.
---
CHANGELOG.md | 5 ++
adoc-mode.el | 81 ++++++++++++++++++++++++++---
doc/spec-compliance.adoc | 7 +--
test/adoc-mode-imenu-test.el | 34 +++++++-----
test/adoc-mode-navigation-test.el | 107 +++++++++++++++++++++++++++++++++++++-
5 files changed, 209 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eff47a9ea9..574f718bb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@
- `[source,ocaml]` code blocks now fontify with `neocaml-mode` when it is
available, falling back to `tuareg-mode` and then `caml-mode`. To support this,
a value in `adoc-code-lang-modes` may now be either a single major mode or a
list of candidate modes tried in order (the first defined one wins).
+### Bugs fixed
+
+- Heading navigation (`C-c C-n` and friends) and the imenu index no longer get
confused by code and other delimited blocks. A `==`-style line inside a
listing, source, literal, example, sidebar, quote, or open block, or a code
line followed by `----` (which looks just like a two-line title underline), is
no longer mistaken for a section title. Navigation and imenu now stay in step
with what is actually highlighted as a title.
+- Heading navigation and imenu now honour `adoc-enable-two-line-title`. It is
nil by default, so two-line (setext) titles are no longer picked up unless you
opt in, matching their fontification. Previously they were always recognised,
which was the main source of the code-block confusion above.
+
## 0.9.0 (2026-06-02)
### New features
diff --git a/adoc-mode.el b/adoc-mode.el
index 2d1209e032..3a48c92577 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -3505,8 +3505,14 @@ and title's text are not preserved, afterwards its
always one space."
;;;; Heading navigation
(defun adoc--re-all-titles ()
- "Return a regexp matching any section title (one-line or two-line)."
- (let* ((two-line-count (length adoc-two-line-title-del))
+ "Return a regexp matching any section title.
+Two-line (setext) titles are only included when
+`adoc-enable-two-line-title' is non-nil, mirroring fontification -
+otherwise a line of body text followed by a delimited-block line
+\(e.g. `----') would be mistaken for a two-line title underline."
+ (let* ((two-line-count (if adoc-enable-two-line-title
+ (length adoc-two-line-title-del)
+ 0))
(core
(mapconcat
(lambda (level)
@@ -3520,11 +3526,61 @@ and title's text are not preserved, afterwards its
always one space."
"\\)\\|\\(?:")))
(concat "\\(?:" core "\\)")))
+(defconst adoc--title-faces
+ '(adoc-title-0-face adoc-title-1-face adoc-title-2-face
+ adoc-title-3-face adoc-title-4-face adoc-title-5-face)
+ "The faces font-lock applies to the text of a section title.")
+
+(defun adoc--face-memq (faces value)
+ "Return non-nil when the `face' text-property VALUE includes a FACES member.
+VALUE may be a face symbol, an anonymous face spec, or a list of
+either."
+ (cond ((symbolp value) (memq value faces))
+ ((listp value) (cl-some (lambda (v) (adoc--face-memq faces v))
value))))
+
+(defun adoc--fontified-as-title-p (start end)
+ "Return non-nil when font-lock fontified START..END as a section title.
+Navigation and imenu use this so that they agree with what is
+highlighted: a `==' line inside a code or other delimited block, and
+a two-line title when `adoc-enable-two-line-title' is disabled, are
+not fontified as titles and so are not treated as headings. The
+region must already be fontified - the heading-navigation commands
+and `adoc-imenu-create-index' call `font-lock-ensure' first.
+
+For a one-line title the leading delimiter at START carries
+`adoc-meta-hide-face'. Inline markup in the title text does not
+touch that delimiter (so a title made entirely of a macro is still
+recognised), whereas a surrounding delimited block repaints the
+whole line and thus the delimiter too - which is precisely why a
+`==' line inside a block is rejected. The check therefore relies on
+the block keywords winning the face-override war on the delimiter;
+the navigation tests guard that assumption. A two-line title has no
+such delimiter, so fall back to looking for a title face on its
+text."
+ (or (adoc--face-memq '(adoc-meta-hide-face) (get-text-property start 'face))
+ (let ((pos start) found)
+ (while (and (not found) (< pos end))
+ (when (adoc--face-memq adoc--title-faces (get-text-property pos
'face))
+ (setq found t))
+ (setq pos (next-single-property-change pos 'face nil end)))
+ found)))
+
+(defun adoc--heading-descriptor-at-point ()
+ "Return the title descriptor at point when it is a navigable heading.
+Like `adoc-title-descriptor', but only accepts a match that
+font-lock actually fontified as a section title (see
+`adoc--fontified-as-title-p'), keeping navigation in step with the
+highlighting."
+ (let ((descriptor (adoc-title-descriptor)))
+ (when (and descriptor
+ (adoc--fontified-as-title-p (nth 4 descriptor) (nth 5
descriptor)))
+ descriptor)))
+
(defun adoc--title-bounds ()
"Return (LEVEL START END) when point is on a section title, else nil.
START and END delimit the whole title (both lines for a two-line
title); point need not be on the first line."
- (let ((descriptor (adoc-title-descriptor)))
+ (let ((descriptor (adoc--heading-descriptor-at-point)))
(when descriptor
(list (nth 2 descriptor) (nth 4 descriptor) (nth 5 descriptor)))))
@@ -3540,10 +3596,11 @@ Point is left unchanged when nil is returned."
(when bounds (goto-char (nth 2 bounds)))
(while (and (not level) (re-search-forward re nil t))
(goto-char (match-beginning 0))
- (let ((descriptor (adoc-title-descriptor)))
+ (let ((descriptor (adoc--heading-descriptor-at-point)))
(if descriptor
(setq level (nth 2 descriptor))
- ;; A regexp match the descriptor rejects; skip the line and retry.
+ ;; A regexp match the descriptor rejects (e.g. a code line
+ ;; inside a listing block); skip the line and retry.
(forward-line 1))))
(unless level (goto-char orig))
level))
@@ -3559,7 +3616,7 @@ Point is left unchanged when nil is returned."
(when bounds (goto-char (nth 1 bounds)))
(while (and (not level) (re-search-backward re nil t))
(goto-char (match-beginning 0))
- (let ((descriptor (adoc-title-descriptor)))
+ (let ((descriptor (adoc--heading-descriptor-at-point)))
(when descriptor (setq level (nth 2 descriptor)))))
(unless level (goto-char orig))
level))
@@ -3581,6 +3638,9 @@ With a negative ARG, move backward (see
(interactive "p")
(if (< arg 0)
(adoc-previous-visible-heading (- arg))
+ ;; Fontify first so `adoc--fontified-as-title-p' can tell real
+ ;; titles from title-looking lines inside code or other blocks.
+ (font-lock-ensure)
(let ((orig (point))
level)
(dotimes (_ arg)
@@ -3597,6 +3657,7 @@ With a negative ARG, move forward (see
(interactive "p")
(if (< arg 0)
(adoc-next-visible-heading (- arg))
+ (font-lock-ensure)
(let ((orig (point))
level)
(dotimes (_ arg)
@@ -3613,6 +3674,7 @@ buffer stops the search. With a negative ARG, move
backward."
(interactive "p")
(if (< arg 0)
(adoc-backward-same-level (- arg))
+ (font-lock-ensure)
(let ((orig (point))
(level (adoc--back-to-heading)))
(dotimes (_ arg)
@@ -3634,6 +3696,7 @@ buffer stops the search. With a negative ARG, move
forward."
(interactive "p")
(if (< arg 0)
(adoc-forward-same-level (- arg))
+ (font-lock-ensure)
(let ((orig (point))
(level (adoc--back-to-heading)))
(dotimes (_ arg)
@@ -3651,6 +3714,7 @@ buffer stops the search. With a negative ARG, move
forward."
(defun adoc-up-heading (arg)
"Move to the visible parent title, ARG levels up."
(interactive "p")
+ (font-lock-ensure)
(let ((orig (point))
(level (adoc--back-to-heading)))
(dotimes (_ arg)
@@ -3883,13 +3947,16 @@ LOCAL-ATTRIBUTE-FACE-ALIST before it is looked up in
(re-all-titles (adoc--re-all-titles)))
(save-restriction
(widen)
+ ;; Fontify so we can skip title-looking lines inside code blocks.
+ (font-lock-ensure)
(goto-char (point-min))
(while (re-search-forward re-all-titles nil t)
(backward-char) ; skip backwards the trailing \n of a title
(let* ((descriptor (adoc-title-descriptor t))
(title-text (nth 3 descriptor))
(title-pos (nth 4 descriptor)))
- (unless (null title-text)
+ (when (and title-text
+ (adoc--fontified-as-title-p title-pos (nth 5 descriptor)))
(setq
index-alist
(cons (cons title-text title-pos) index-alist))))))
diff --git a/doc/spec-compliance.adoc b/doc/spec-compliance.adoc
index 5c4362566b..f4bd5a7323 100644
--- a/doc/spec-compliance.adoc
+++ b/doc/spec-compliance.adoc
@@ -226,9 +226,10 @@ Findings are graded:
| -
| Two-line (setext) titles
-| Editing commands (promote/demote, imenu, navigation) understand them,
- but highlighting is *off by default* (`adoc-enable-two-line-title` is
- nil) - which matches their deprecated status
+| Off by default (`adoc-enable-two-line-title` is nil), matching their
+ deprecated status. Highlighting, navigation, and imenu all honour the
+ flag, so they ignore two-line titles unless you opt in; promote/demote
+ still understand them so old documents can be converted
| -
| Auto-generated IDs, section refs `<<id>>`
diff --git a/test/adoc-mode-imenu-test.el b/test/adoc-mode-imenu-test.el
index 1816dd8cf8..d6c268d344 100644
--- a/test/adoc-mode-imenu-test.el
+++ b/test/adoc-mode-imenu-test.el
@@ -15,35 +15,41 @@
(it "builds a flat index, ignoring non-title content"
(with-temp-buffer
(adoc-mode)
+ ;; The `==' lines inside the example and source blocks must not be
+ ;; picked up as titles.
(insert "= document title\n"
"== chapter 1\n"
"[IMPORTANT]\n"
".Important announcement\n"
"====\n"
- "This should not be a title\n"
+ "== this should not be a title\n"
"====\n"
"=== sub chapter 1.1\n"
"[source,rust]\n"
"----\n"
"// here is a comment\n"
"----\n"
- "chapter 2\n"
- "----------\n"
- "[latexmath#einstein]\n"
- "++++\n"
- "\begin{equation}\n"
- "e = mc^{2}\n"
- "\end{equation}\n"
- "++++\n"
- "sub chapter 2.1\n"
- "~~~~~~~~~~~~~~\n")
+ "== chapter 2\n"
+ "=== sub chapter 2.1\n")
(expect (adoc-imenu-create-index)
:to-equal
(list (cons "document title" 1)
(cons "chapter 1" 18)
- (cons "sub chapter 1.1" 104)
- (cons "chapter 2" 169)
- (cons "sub chapter 2.1" 262)))))
+ (cons "sub chapter 1.1" 107)
+ (cons "chapter 2" 172)
+ (cons "sub chapter 2.1" 185)))))
+
+ (it "indexes two-line titles only when they are enabled"
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "= One-line Doc\n\nintro\n\nTwo Line\n========\n\nbody\n")
+ ;; Off by default: the setext title is not fontified, so not indexed.
+ (expect (adoc-imenu-create-index)
+ :to-equal (list (cons "One-line Doc" 1)))
+ (setq-local adoc-enable-two-line-title t)
+ (expect (adoc-imenu-create-index)
+ :to-equal (list (cons "One-line Doc" 1)
+ (cons "Two Line" 24)))))
(it "builds a nested index reflecting the heading hierarchy"
(with-temp-buffer
diff --git a/test/adoc-mode-navigation-test.el
b/test/adoc-mode-navigation-test.el
index 490170a32a..0b7cbbc27c 100644
--- a/test/adoc-mode-navigation-test.el
+++ b/test/adoc-mode-navigation-test.el
@@ -115,6 +115,8 @@
(it "navigates two-line (setext) titles"
(with-temp-buffer
(adoc-mode)
+ ;; Two-line titles are off by default; opt in for this test.
+ (setq-local adoc-enable-two-line-title t)
(insert "Doc Title\n=========\n\n"
"intro\n\n"
"Section A\n---------\n\n"
@@ -127,7 +129,110 @@
(adoc-forward-same-level 1)
(expect (looking-at-p "Section B$") :to-be-truthy)
(adoc-up-heading 1)
- (expect (looking-at-p "Doc Title$") :to-be-truthy)))))
+ (expect (looking-at-p "Doc Title$") :to-be-truthy))))
+
+ (describe "two-line titles disabled by default"
+ (it "does not treat setext underlines as headings"
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "Doc Title\n=========\n\n"
+ "intro\n\n"
+ "Section A\n---------\n\n"
+ "text\n")
+ (goto-char (point-min))
+ ;; adoc-enable-two-line-title is nil, so navigation finds nothing.
+ (let ((pos (point)))
+ (expect (adoc-next-visible-heading 1) :to-throw 'user-error)
+ (expect (point) :to-equal pos)))))
+
+ (describe "code and verbatim blocks"
+ (it "skips one-line titles inside a listing block"
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "= Doc\n\n"
+ "== Real A\n\ntext\n\n"
+ "----\n"
+ "== this is code, not a heading\n"
+ "more code\n"
+ "----\n\n"
+ "== Real B\n\nbody\n")
+ (goto-char (point-min))
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== Real A$") :to-be-truthy)
+ ;; Jumps straight over the listing block to the next real title.
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== Real B$") :to-be-truthy)))
+
+ (it "skips one-line titles inside an example block"
+ ;; Example/sidebar/quote/open blocks are not fontified as titles
+ ;; either, so a `==' line inside them is not a heading.
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "= Doc\n\n"
+ "== Real A\n\ntext\n\n"
+ "====\n"
+ "== this is block content, not a heading\n"
+ "====\n\n"
+ "== Real B\n\nbody\n")
+ (goto-char (point-min))
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== Real A$") :to-be-truthy)
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== Real B$") :to-be-truthy)))
+
+ (it "still recognizes a title whose text is entirely a macro"
+ ;; The link/image keyword overrides the title face on the text, but
+ ;; the leading `==' delimiter keeps its title highlighting.
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "= Doc\n\n"
+ "== https://example.com[Home]\n\nbody\n\n"
+ "== image:logo.png[Logo]\n\nmore\n")
+ (goto-char (point-min))
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== https://example.com\\[Home]$") :to-be-truthy)
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== image:logo.png\\[Logo]$") :to-be-truthy)))
+
+ (it "skips a code line that mimics a two-line title underline"
+ (with-temp-buffer
+ (adoc-mode)
+ ;; `end' followed by `----' (the closing listing delimiter) looks
+ ;; exactly like a two-line title - it must not stop navigation.
+ (insert "= Doc\n\n"
+ "== Real A\n\ntext\n\n"
+ "[source,ruby]\n----\ndef foo\nend\n----\n\n"
+ "== Real B\n\nbody\n")
+ (goto-char (point-min))
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== Real A$") :to-be-truthy)
+ (adoc-next-visible-heading 1)
+ (expect (looking-at-p "== Real B$") :to-be-truthy)))
+
+ (it "climbs out of a block when navigating backward"
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "= Doc\n\n"
+ "== Real A\n\ntext\n\n"
+ "----\n== fake heading\nmore\n----\n\n"
+ "== Real B\n\nbody\n")
+ ;; Start inside the block and move to the previous real heading.
+ (goto-char (point-min))
+ (search-forward "more")
+ (beginning-of-line)
+ (adoc-previous-visible-heading 1)
+ (expect (looking-at-p "== Real A$") :to-be-truthy)))
+
+ (it "omits code-block lines from the imenu index"
+ (with-temp-buffer
+ (adoc-mode)
+ (insert "= Doc\n\n"
+ "== Real A\n\n"
+ "----\n== not a heading\n----\n\n"
+ "== Real B\n")
+ (expect (mapcar (lambda (e) (substring-no-properties (car e)))
+ (adoc-imenu-create-index))
+ :to-equal '("Doc" "Real A" "Real B"))))))
(describe "adoc-mode outline cycling"