Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-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
Expand Down
1 change: 1 addition & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 111 additions & 7 deletions adoc-mode.el
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."
Expand All @@ -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)))
Expand Down Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions test/adoc-mode-antora-test.el
Original file line number Diff line number Diff line change
@@ -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