branch: elpa/adoc-mode
commit 4547693e077d4cb9098451c8a62ebdb2c8763a7e
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    Follow Antora cross-file cross-references
    
    In an Antora component (a tree with an antora.yml), following an xref to
    another page - xref:topic/page.adoc[] or xref:other.adoc#a-section[],
    optionally with a module: prefix - now opens the resolved page (under the
    target module's pages/ directory) and jumps to the #fragment section,
    reusing the section auto-id resolver. Works from C-c C-o / M-. and a
    mouse click, pushes the xref marker stack so M-, returns, and stays out
    of the way (falls through) outside Antora. Resolution is limited to the
    current component; version@/cross-component targets are left alone.
    
    Phase 2 of the Antora/xref work; completion of xref page targets and
    project-wide references are still to come.
---
 CHANGELOG.md                  |   1 +
 README.adoc                   |   1 +
 adoc-mode.el                  | 118 +++++++++++++++++++++++++++++++--
 test/adoc-mode-antora-test.el | 151 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 264 insertions(+), 7 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6440167106..83e156ee5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
 - Add a Flymake backend (`adoc-flymake`) that runs the buffer through 
Asciidoctor and reports its parser errors and warnings inline. It's registered 
automatically, so enabling `flymake-mode` is enough. The check feeds the buffer 
to Asciidoctor over its standard input, so it works on unsaved edits.
 - Make references clickable. Cross-references (`<<id>>`, `xref:id[]`), links 
and URLs (`link:`, `https:`, `mailto:`, ...), and `include::` macros now 
highlight on hover and follow with a `mouse-1` (or `mouse-2`) click - the same 
action as `C-c C-o` / `M-.`. As part of this, `adoc-follow-thing-at-point` now 
also follows `link:` macros (opening a local target or a URL) and no longer 
passes the `[label]` along when opening a URL macro.
 - Add an `xref` backend over AsciiDoc anchors. In an `adoc-mode` buffer, `M-?` 
(`xref-find-references`) lists every cross-reference to the anchor at point, 
and the standard xref machinery (the marker stack, the completion-read prompt, 
`consult-xref`, ...) now works for AsciiDoc ids. Definitions are anchors 
(`[[id]]`, `[#id]`, `[[[biblio]]]`) and references are `<<id>>` / `xref:id[]` 
usages, resolved within the current buffer. `M-.` keeps following URLs and 
`include::` too, via `adoc-foll [...]
+- Follow Antora cross-file cross-references. In a file inside an Antora 
component (one with an `antora.yml` above it), following an `xref:` that 
targets a page - e.g. `xref:basics/install.adoc[]` or 
`xref:other.adoc#a-section[]`, including a `module:` prefix - now opens the 
resolved page (under the target module's `pages/` directory) and jumps to the 
`#fragment` section. Works from `C-c C-o` / `M-.` and a mouse click, and `M-,` 
(`xref-go-back`) returns. Resolution is limited to the curre [...]
 - Treat section titles as cross-reference targets. `adoc-mode` now derives 
each section's auto-id the way Asciidoctor does, so completion (`<<` / 
`xref:`), the `xref` backend, and `adoc-goto-ref-label` offer and resolve 
section ids - not just explicit anchors. The id style is detected 
automatically: a document's own `:idprefix:` / `:idseparator:` win, otherwise 
files inside an Antora component (an `antora.yml` above them) use Antora's 
kebab-case style (`My Title` -> `my-title`) and every [...]
 
 ### Changes
diff --git a/README.adoc b/README.adoc
index 37e90e0828..56ae50e525 100644
--- a/README.adoc
+++ b/README.adoc
@@ -50,6 +50,7 @@ Here are some of the main features of `adoc-mode`:
 - list editing: `M-left` / `M-right` nest the list item at point deeper or 
shallower, `M-RET` inserts a sibling item (incrementing the number for 
explicitly-numbered lists), `M-up` / `M-down` move an item (with its sub-items) 
past its siblings, and `M-x adoc-renumber-list` renumbers an 
explicitly-numbered list
 - navigate to anchors and sections with completion (`C-c C-a`), and follow 
URLs, `link:` and `include::` macros, and xrefs at point (`C-c C-o` / `M-.`), 
or by clicking them with the mouse
 - section titles act as cross-reference targets too: their Asciidoctor 
auto-ids are offered in completion and resolved by navigation, with the id 
style (Asciidoctor `_my_title` vs Antora `my-title`) detected automatically or 
set via `adoc-section-id-style`
+- Antora awareness: in a component (a directory with an `antora.yml`), 
following an `xref:` to another page (e.g. `xref:topic/page.adoc#section[]`) 
opens the resolved page under the right module's `pages/` directory and jumps 
to the section
 - an `xref` backend over anchors and sections: `M-?` lists every 
cross-reference to the id at point, with the usual xref marker stack and 
completion UI
 - context-aware completion via `completion-at-point`: cross-reference ids 
inside `<<` / `xref:`, attribute names inside `{`, file paths after 
`include::`, and source-block languages inside `[source,`
 - nested `imenu` index with hierarchical heading structure
diff --git a/adoc-mode.el b/adoc-mode.el
index dd3fe4dd89..caccd62742 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -2938,14 +2938,13 @@ for multiline constructs to be matched."
                    (delete-dups (append (adoc--collect-anchor-ids)
                                         (adoc--collect-section-ids)))
                    nil nil nil nil default))))
-  (let ((pos (or (save-excursion
-                   (goto-char (point-min))
-                   (re-search-forward (adoc-re-anchor nil id) nil t))
-                 ;; fall back to a section whose auto-id (or title) matches
-                 (adoc--section-position id))))
-    (if (null pos) (user-error "Can't find an anchor defining '%s'" id))
+  (let ((target (save-excursion
+                  ;; resolve against an explicit anchor or a section (auto-id
+                  ;; or title) without moving point yet
+                  (when (adoc--goto-id id) (point)))))
+    (if (null target) (user-error "Can't find an anchor defining '%s'" id))
     (push-mark)
-    (goto-char pos)))
+    (goto-char target)))
 
 (defun adoc--inline-link-at-point ()
   "Return the target of an inline link or URL macro covering point, or nil.
@@ -2973,6 +2972,8 @@ URL schemes (the attribute list / label is dropped)."
 
 (defun adoc-follow-thing-at-point ()
   "Follow the link or reference at point.
+When point is on an Antora page `xref:' (e.g. `xref:other.adoc#frag[]'),
+open the resolved page and jump to its fragment.
 When point is on a URL or `link:' macro, open it.
 When point is on an `include::' macro, open the referenced file.
 When point is on an xref or cross-reference, jump to its anchor."
@@ -2986,6 +2987,24 @@ When point is on an xref or cross-reference, jump to its 
anchor."
       (if (file-exists-p file)
           (find-file file)
         (user-error "File not found: %s" file))))
+   ;; Antora page xref — resolve to a file in the component and open it
+   ;; (only in an Antora component, so a plain `.adoc' xref elsewhere falls
+   ;; through to the in-buffer handling).
+   ((and (adoc--antora-root) (adoc--antora-page-xref-at-point))
+    (let* ((target (adoc--antora-page-xref-at-point))
+           (resolved (adoc--antora-resolve-page target)))
+      (cond
+       ((null resolved)
+        (user-error "Cannot resolve Antora xref: %s" target))
+       ((not (file-exists-p (car resolved)))
+        (user-error "Antora xref target not found: %s" (car resolved)))
+       (t
+        (xref-push-marker-stack)
+        (find-file (car resolved))
+        (when (cdr resolved)
+          (or (adoc--goto-id (cdr resolved))
+              (message "No anchor or section `%s' in %s"
+                       (cdr resolved) (file-name-nondirectory (car 
resolved)))))))))
    ;; xref at point — jump to anchor
    ((adoc-xref-id-at-point)
     (adoc-goto-ref-label (adoc-xref-id-at-point)))
@@ -3729,6 +3748,91 @@ cross-reference, when its title does."
                                  (xref-make-buffer-location buffer (nth 2 
s)))))
                   (adoc--collect-sections)))))
 
+(defun adoc--goto-id (id)
+  "Move point to the anchor or section identified by ID in this buffer.
+Search explicit anchors first (`[[id]]', `[#id]', ...), then fall back
+to a section whose auto-id or title matches.  Return non-nil on success,
+leaving point on the target; return nil and do not move otherwise."
+  (let ((pos (or (save-excursion
+                   (goto-char (point-min))
+                   (re-search-forward (adoc-re-anchor nil id) nil t))
+                 (adoc--section-position id))))
+    (when pos
+      (goto-char pos)
+      t)))
+
+;;;; Antora cross-references
+
+;; Antora lays a documentation component out as `<root>/antora.yml' plus
+;; `<root>/modules/<module>/pages/...'.  Cross-references between pages use a
+;; resource id - `xref:[module:]relative/path.adoc[#fragment][text]' - where
+;; the path is relative to the target module's `pages' directory.  Resolution
+;; here is intentionally limited to the current component (cross-component and
+;; explicit `version@' targets need Antora's site catalog, which we lack).
+
+(defun adoc--antora-root ()
+  "Return the Antora component root (the dir holding `antora.yml'), or nil."
+  (and buffer-file-name
+       (locate-dominating-file buffer-file-name "antora.yml")))
+
+(defun adoc--antora-current-module (root file)
+  "Return the Antora module name FILE lives in, relative to ROOT, or nil."
+  (let ((rel (file-relative-name file root)))
+    (when (string-match "\\`modules/\\([^/]+\\)/" rel)
+      (match-string 1 rel))))
+
+(defun adoc--antora-resolve-page (target)
+  "Resolve an Antora page xref TARGET to (FILE . FRAGMENT), or nil.
+TARGET is the raw xref target, e.g. `basics/install.adoc#frag' or
+`other-module:page.adoc'.  FRAGMENT is nil when none is given.  Returns
+nil when the buffer is not in an Antora component or the target names a
+different component (out of scope)."
+  (let ((root (adoc--antora-root)))
+    (when (and root buffer-file-name)
+      (let ((module (adoc--antora-current-module root buffer-file-name))
+            (coord target)
+            (fragment nil))
+        ;; Drop a leading `version@', but only when a module/component
+        ;; coordinate follows (an `@' before the first `:') so a filename
+        ;; that merely contains `@' is left intact.
+        (let ((at (string-match "@" coord))
+              (colon (string-match ":" coord)))
+          (when (and at colon (< at colon))
+            (setq coord (substring coord (1+ at)))))
+        ;; Split off the `#fragment'; an empty fragment counts as none.
+        (when (string-match "#" coord)
+          (let ((frag (substring coord (1+ (match-beginning 0)))))
+            (setq fragment (unless (string-empty-p frag) frag)
+                  coord (substring coord 0 (match-beginning 0)))))
+        (let* ((parts (split-string coord ":"))
+               (path (pcase (length parts)
+                       (1 (car parts))
+                       (2 (setq module (car parts)) (cadr parts))
+                       (_ nil))))           ; component:module:path -> out of 
scope
+          (when (and path module)
+            ;; Strip a leading family coordinate (e.g. `page$').
+            (setq path (replace-regexp-in-string "\\`[a-z]+\\$" "" path))
+            (cons (expand-file-name (concat "modules/" module "/pages/" path) 
root)
+                  fragment)))))))
+
+(defun adoc--antora-page-xref-at-point ()
+  "Return the page-xref TARGET at point, or nil.
+Only an `xref:' whose target names a `.adoc' page (as opposed to an
+in-buffer id) qualifies."
+  (save-excursion
+    (let ((pos (point))
+          (eol (line-end-position))
+          (re (adoc-re-inline-macro "xref")))
+      (beginning-of-line)
+      (catch 'found
+        (while (re-search-forward re eol t)
+          (when (and (<= (match-beginning 0) pos) (<= pos (match-end 0)))
+            (let ((target (match-string-no-properties 3)))
+              (throw 'found
+                     (and (string-match-p "\\.adoc\\(?:#\\|\\'\\)" target)
+                          target)))))
+        nil))))
+
 ;;;; Completion
 
 (defconst adoc-intrinsic-attributes
diff --git a/test/adoc-mode-antora-test.el b/test/adoc-mode-antora-test.el
new file mode 100644
index 0000000000..c050965604
--- /dev/null
+++ b/test/adoc-mode-antora-test.el
@@ -0,0 +1,151 @@
+;;; adoc-mode-antora-test.el --- Antora cross-reference tests -*- 
lexical-binding: t; -*-
+
+;; Copyright © 2026 Bozhidar Batsov
+
+;;; Commentary:
+
+;; Buttercup tests for Antora cross-file xref support: resolving a page xref
+;; target to a file within the component, detecting a page xref at point, and
+;; following one to the target page (and its `#fragment' section).
+
+;;; Code:
+
+(require 'adoc-mode-test-helpers)
+
+(defun adoc-test--make-antora (files)
+  "Create a temp Antora component and return its root.
+FILES is an alist of (RELPATH . CONTENT) created under
+`modules/ROOT/pages/'."
+  (let* ((root (make-temp-file "adoc-antora-" t))
+         (pages (expand-file-name "modules/ROOT/pages" root)))
+    (make-directory pages t)
+    (with-temp-file (expand-file-name "antora.yml" root)
+      (insert "name: demo\nversion: ~\n"))
+    (dolist (f files)
+      (let ((file (expand-file-name (car f) pages)))
+        (make-directory (file-name-directory file) t)
+        (with-temp-file file (insert (cdr f)))))
+    root))
+
+(describe "adoc--antora-resolve-page"
+  (it "resolves page targets within the component"
+    (let ((root (adoc-test--make-antora '(("a.adoc" . "= A\n")))))
+      (unwind-protect
+          (with-current-buffer
+              (find-file-noselect (expand-file-name 
"modules/ROOT/pages/a.adoc" root))
+            (unwind-protect
+                (progn
+                  ;; bare path, no fragment
+                  (expect (adoc--antora-resolve-page "basics/install.adoc")
+                          :to-equal
+                          (cons (expand-file-name
+                                 "modules/ROOT/pages/basics/install.adoc" root)
+                                nil))
+                  ;; path + fragment
+                  (expect (cdr (adoc--antora-resolve-page "p.adoc#frag"))
+                          :to-equal "frag")
+                  ;; module: prefix selects another module
+                  (expect (car (adoc--antora-resolve-page "other:p.adoc"))
+                          :to-equal
+                          (expand-file-name "modules/other/pages/p.adoc" root))
+                  ;; version@ before a coordinate is stripped
+                  (expect (car (adoc--antora-resolve-page "2.0@other:p.adoc"))
+                          :to-equal
+                          (expand-file-name "modules/other/pages/p.adoc" root))
+                  ;; an `@' in a bare filename is NOT treated as a version
+                  (expect (car (adoc--antora-resolve-page "[email protected]"))
+                          :to-equal
+                          (expand-file-name "modules/ROOT/pages/[email protected]" 
root))
+                  ;; an empty fragment counts as none
+                  (expect (cdr (adoc--antora-resolve-page "p.adoc#")) :to-be 
nil)
+                  ;; cross-component (component:module:path) is out of scope
+                  (expect (adoc--antora-resolve-page "comp:mod:p.adoc") :to-be 
nil))
+              (kill-buffer)))
+        (delete-directory root t))))
+
+  (it "returns nil outside an Antora component"
+    (with-temp-buffer
+      (setq buffer-file-name "/tmp/not-antora.adoc")
+      (expect (adoc--antora-resolve-page "p.adoc") :to-be nil))))
+
+(describe "following a .adoc xref outside an Antora component"
+  (it "does not hijack the in-buffer xref handling"
+    ;; A plain `.adoc' xref in a non-Antora buffer must fall through to the
+    ;; in-buffer xref branch (which then can't find the id), not raise the
+    ;; Antora \"Cannot resolve\" error.
+    (with-temp-buffer
+      (setq buffer-file-name "/tmp/plain-not-antora.adoc")
+      (adoc-mode)
+      (insert "see xref:foo.adoc[x] here")
+      (goto-char (point-min))
+      (search-forward "foo")
+      (let (err)
+        (condition-case e (adoc-follow-thing-at-point)
+          (user-error (setq err (error-message-string e))))
+        (expect err :not :to-match "Antora")))))
+
+(describe "adoc--antora-page-xref-at-point"
+  (it "detects a .adoc page xref at point"
+    (with-temp-buffer
+      (adoc-mode)
+      (insert "see xref:sub/target.adoc#sec[label] here")
+      (goto-char (point-min))
+      (search-forward "target")
+      (expect (adoc--antora-page-xref-at-point)
+              :to-equal "sub/target.adoc#sec")))
+
+  (it "ignores an in-buffer xref to a plain id"
+    (with-temp-buffer
+      (adoc-mode)
+      (insert "see xref:some-section-id[label] here")
+      (goto-char (point-min))
+      (search-forward "some")
+      (expect (adoc--antora-page-xref-at-point) :to-be nil)))
+
+  (it "returns nil away from any xref"
+    (with-temp-buffer
+      (adoc-mode)
+      (insert "just prose")
+      (goto-char (point-min))
+      (expect (adoc--antora-page-xref-at-point) :to-be nil))))
+
+(describe "following an Antora page xref"
+  (it "opens the target page and jumps to the fragment section"
+    (let ((root (adoc-test--make-antora
+                 '(("sub/target.adoc" . "= Target\n\n== Deep Section\n\nhi\n")
+                   ("src.adoc" . "= Src\n\nSee 
xref:sub/target.adoc#deep-section[x].\n")))))
+      (unwind-protect
+          (let ((src (find-file-noselect
+                      (expand-file-name "modules/ROOT/pages/src.adoc" root))))
+            (unwind-protect
+                (with-current-buffer src
+                  (goto-char (point-min))
+                  (search-forward "xref:")
+                  (adoc-follow-thing-at-point)
+                  ;; now visiting the target page, point on the fragment 
heading
+                  (expect (file-name-nondirectory (buffer-file-name))
+                          :to-equal "target.adoc")
+                  (expect (buffer-substring-no-properties
+                           (line-beginning-position) (line-end-position))
+                          :to-equal "== Deep Section")
+                  (kill-buffer))
+              (when (buffer-live-p src) (kill-buffer src))))
+        (delete-directory root t))))
+
+  (it "errors when the target page does not exist"
+    (let ((root (adoc-test--make-antora
+                 '(("src.adoc" . "= Src\n\nSee xref:nope.adoc[x].\n")))))
+      (unwind-protect
+          (with-current-buffer
+              (find-file-noselect (expand-file-name 
"modules/ROOT/pages/src.adoc" root))
+            (unwind-protect
+                (progn
+                  (goto-char (point-min))
+                  (search-forward "xref:")
+                  (expect (adoc-follow-thing-at-point) :to-throw 'user-error))
+              (kill-buffer)))
+        (delete-directory root t)))))
+
+(provide 'adoc-mode-antora-test)
+
+;;; adoc-mode-antora-test.el ends here

Reply via email to