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