From 81eb860294c390fee1f5348af0cebdd74b14e09a Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 5 Jun 2026 14:41:58 +0300 Subject: [PATCH] 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(-) create mode 100644 test/adoc-mode-antora-test.el diff --git a/CHANGELOG.md b/CHANGELOG.md index 6440167..83e156e 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 (`<>`, `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 `<>` / `xref:id[]` usages, resolved within the current buffer. `M-.` keeps following URLs and `include::` too, via `adoc-follow-thing-at-point`. +- 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 current component. - 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 everything else uses Asciidoctor's default (`_my_title`). The new `adoc-section-id-style` option forces a specific style. ### Changes diff --git a/README.adoc b/README.adoc index 37e90e0..56ae50e 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 dd3fe4d..caccd62 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 `/antora.yml' plus +;; `/modules//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 0000000..c050965 --- /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 "pa@ge.adoc")) + :to-equal + (expand-file-name "modules/ROOT/pages/pa@ge.adoc" 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