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"
 

Reply via email to