branch: elpa/adoc-mode
commit 2f09c570b7f1483bd31514c4e0fea2ad6b4fbc72
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
Add an xref backend over AsciiDoc anchors
Register a single-buffer xref backend so M-? (xref-find-references) lists
every <<id>>/xref:id[] usage of the anchor at point, and the rest of the
xref machinery (marker stack, completion prompt, consult-xref) works for
AsciiDoc ids. Definitions are anchors ([[id]], [#id], [[[biblio]]]),
references are the xrefs that point at them. M-. stays on
adoc-follow-thing-at-point, which also handles URLs and include::.
Most of the backend reuses existing pieces (adoc-xref-id-at-point,
adoc--collect-anchor-ids, adoc-re-anchor). Along the way, fix
adoc-forward-xref to return the nearest xref of any form instead of
greedily preferring the captioned one (so a plain <<id>> before a
<<id,caption>> is no longer skipped), and trim whitespace out of the id
so <<id >> resolves - both of which also fix C-c C-o / M-. following.
---
CHANGELOG.md | 2 +
README.adoc | 1 +
adoc-mode.el | 130 ++++++++++++++++++++++++++++++++++----
test/adoc-mode-navigation-test.el | 66 +++++++++++++++++++
4 files changed, 187 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7adddace06..e04a01ea5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- Add context-aware completion via `completion-at-point` (kbd:[M-TAB], or any
of corfu/company/built-in completion). Inside `<<` or `xref:` it completes
cross-reference ids from the explicit anchors defined in the buffer (`[[id]]`,
`[#id]`, `[[[biblio]]]`); inside `{` it completes attribute names (the ones
defined with `:name:` plus a set of common built-ins); after `include::` it
completes file paths; and inside `[source,` it completes source-block language
names. It stays out of the wa [...]
- 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 [...]
### Changes
@@ -16,6 +17,7 @@
### Bugs fixed
+- Following a cross-reference at point (`C-c C-o` / `M-.`, and the new `xref`
commands) now works for a plain `<<id>>` even when a captioned `<<id,caption>>`
appears later on the same or an adjacent line, and ignores the whitespace in
forms like `<<id >>`. Previously `adoc-xref-id-at-point` could return nil or an
id with a trailing space in those cases.
- 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.
diff --git a/README.adoc b/README.adoc
index 1002705428..b24fffca78 100644
--- a/README.adoc
+++ b/README.adoc
@@ -49,6 +49,7 @@ Here are some of the main features of `adoc-mode`:
- title management: promote / demote (`M-left` / `M-right`), toggle between
one-line and two-line styles, adjust underline length
- 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 (`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
+- an `xref` backend over anchors: `M-?` lists every cross-reference to the
anchor 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
- outline folding built on `outline-minor-mode` (enabled out of the box):
`TAB` cycles the subtree at point, `S-TAB` cycles the whole buffer (overview /
contents / show all), one-line title style only
diff --git a/adoc-mode.el b/adoc-mode.el
index a85402d6e4..1f1d7d5eb6 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -51,6 +51,7 @@
(require 'compile)
(require 'outline)
(require 'subr-x)
+(require 'xref)
(require 'adoc-mode-image)
(require 'adoc-mode-tempo)
(require 'adoc-asciidoctor)
@@ -3369,17 +3370,35 @@ new customization demands."
(defun adoc-forward-xref (&optional bound)
- "Move forward to next xref and return its id.
-
-Match data is the one of the found xref. Returns nil if there was
-no xref found."
- (cond
- ((or (re-search-forward (adoc-re-xref 'inline-special-with-caption) bound t)
- (re-search-forward (adoc-re-xref 'inline-special-no-caption) bound t))
- (match-string-no-properties 2))
- ((re-search-forward (adoc-re-xref 'inline-general-macro) bound t)
- (match-string-no-properties 3))
- (t nil)))
+ "Move forward to the next xref and return its id.
+
+Search for the nearest cross-reference of any form - `<<id>>',
+`<<id,caption>>' or `xref:id[...]' - set the match data to it and move
+point to its end. Return nil, leaving point unmoved, when none is found
+before BOUND.
+
+Searching for each form independently and taking the earliest match
+matters: a plain `<<id>>' must not be skipped just because a
+`<<id,caption>>' happens to appear later in the search range."
+ (let ((start (point))
+ (best nil)
+ (best-id nil)
+ (best-data nil))
+ (dolist (spec '((inline-special-with-caption . 2)
+ (inline-special-no-caption . 2)
+ (inline-general-macro . 3)))
+ (goto-char start)
+ (when (and (re-search-forward (adoc-re-xref (car spec)) bound t)
+ (or (null best) (< (match-beginning 0) best)))
+ (setq best (match-beginning 0)
+ best-id (match-string-no-properties (cdr spec))
+ best-data (match-data))))
+ (if best
+ (progn (set-match-data best-data)
+ (goto-char (match-end 0))
+ best-id)
+ (goto-char start)
+ nil)))
(defun adoc-xref-id-at-point ()
"Returns id referenced by the xref point is at.
@@ -3395,7 +3414,9 @@ Returns nil if there was no xref found."
(while (and (setq id (adoc-forward-xref end))
(or (< saved-point (match-beginning 0))
(> saved-point (match-end 0)))))
- id)))
+ ;; `adoc-re-xref' captures trailing whitespace inside the id group
+ ;; (e.g. `<<foo >>'); the actual anchor id has none.
+ (and id (string-trim id)))))
(defun adoc-title-descriptor (&optional strict-match )
"Returns title descriptor of title point is in.
@@ -3737,6 +3758,88 @@ inside `[source,'."
:company-kind (lambda (_) 'file)
:exclusive 'no)))))
+;;;; xref backend
+
+;; A single-buffer `xref' backend over AsciiDoc anchors: definitions are
+;; anchors (`[[id]]', `[#id]', `[[[biblio]]]') and references are the xrefs
+;; that point at them (`<<id>>', `<<id,text>>', `xref:id[...]'). Registering
+;; it lights up `M-?' (`xref-find-references') and the xref marker stack;
+;; `M-.' stays on `adoc-follow-thing-at-point' (which also follows URLs and
+;; `include::').
+
+(defun adoc--xref-backend ()
+ "Return the `xref' backend for `adoc-mode'."
+ 'adoc)
+
+(defun adoc--anchor-id-at-point ()
+ "Return the id of the anchor definition point is on, or nil."
+ (save-excursion
+ (let ((pos (point))
+ (eol (line-end-position))
+ (found nil))
+ (beginning-of-line)
+ (dolist (type '(block-id block-id-shorthand inline-special biblio) found)
+ (unless found
+ (save-excursion
+ (let ((re (adoc-re-anchor type)))
+ (while (and (not found) (re-search-forward re eol t))
+ (when (and (<= (match-beginning 0) pos) (<= pos (match-end 0)))
+ (setq found
+ (pcase type
+ ((or 'block-id 'block-id-shorthand)
+ (match-string-no-properties 1))
+ ('inline-special
+ (car (split-string (match-string-no-properties 2)
+ "[ \t,]" t)))
+ ('biblio
+ (string-trim (match-string-no-properties 2)
+ "\\[" "\\]"))))))))))) ))
+
+(defun adoc--re-xref-to (id)
+ "Return a regexp matching a cross-reference to the anchor ID.
+Trailing whitespace is tolerated after the id (as in `<<foo >>'), the
+same way `adoc-re-xref' permits it."
+ (let ((q (regexp-quote id)))
+ (concat "<<" q "[ \t\n]*\\(?:,\\|>>\\)\\|xref:" q "\\[")))
+
+(defun adoc--xref-collect (regexp)
+ "Return a list of xref items, one per match of REGEXP in the buffer.
+Each item's summary is the matched line; its location is the start of
+the match."
+ (let ((buffer (current-buffer))
+ (items '()))
+ (save-excursion
+ (save-match-data
+ (goto-char (point-min))
+ (while (re-search-forward regexp nil t)
+ (let ((pos (match-beginning 0))
+ (summary (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (push (xref-make (string-trim summary)
+ (xref-make-buffer-location buffer pos))
+ items)))))
+ (nreverse items)))
+
+(cl-defmethod xref-backend-identifier-at-point ((_backend (eql adoc)))
+ (or (adoc-xref-id-at-point)
+ (adoc--anchor-id-at-point)))
+
+(cl-defmethod xref-backend-identifier-completion-table ((_backend (eql adoc)))
+ (adoc--collect-anchor-ids))
+
+(cl-defmethod xref-backend-definitions ((_backend (eql adoc)) identifier)
+ (adoc--xref-collect (adoc-re-anchor nil identifier)))
+
+(cl-defmethod xref-backend-references ((_backend (eql adoc)) identifier)
+ (adoc--xref-collect (adoc--re-xref-to identifier)))
+
+(cl-defmethod xref-backend-apropos ((_backend (eql adoc)) pattern)
+ (require 'apropos) ; for `apropos-parse-pattern'
+ (let ((re (xref-apropos-regexp pattern)))
+ (cl-loop for id in (adoc--collect-anchor-ids)
+ when (string-match-p re id)
+ append (adoc--xref-collect (adoc-re-anchor nil id)))))
+
;;;; Heading navigation
(defun adoc--re-all-titles ()
@@ -4555,6 +4658,9 @@ Turning on Adoc mode runs the normal hook
`adoc-mode-hook'."
;; diagnostics (opt-in: contributes when the user enables `flymake-mode')
(add-hook 'flymake-diagnostic-functions #'adoc-flymake nil t)
+ ;; cross-references (`M-?' for references, the xref marker stack, etc.)
+ (add-hook 'xref-backend-functions #'adoc--xref-backend nil t)
+
;; misc
(setq-local page-delimiter "^<<<+$")
(setq-local require-final-newline mode-require-final-newline)
diff --git a/test/adoc-mode-navigation-test.el
b/test/adoc-mode-navigation-test.el
index dd83314367..a08d7a46e9 100644
--- a/test/adoc-mode-navigation-test.el
+++ b/test/adoc-mode-navigation-test.el
@@ -374,4 +374,70 @@
(goto-char (point-min))
(expect (adoc--inline-link-at-point) :to-be nil))))
+(describe "xref backend"
+ (defmacro adoc-test--with-xref-doc (&rest body)
+ "Run BODY in an adoc-mode buffer holding a doc with anchors and xrefs."
+ `(with-temp-buffer
+ (insert "[[intro]]\n= Doc\n\n"
+ "See <<intro>> and <<intro,the intro>>.\n\n"
+ "More in xref:intro[here].\n\n"
+ "[[other]]\nunrelated\n")
+ (adoc-mode)
+ (goto-char (point-min))
+ ,@body))
+
+ (it "is selected as the backend in adoc-mode buffers"
+ (adoc-test--with-xref-doc
+ (expect (xref-find-backend) :to-be 'adoc)))
+
+ (it "offers all anchor ids as the completion table"
+ (adoc-test--with-xref-doc
+ (expect (sort (xref-backend-identifier-completion-table 'adoc) #'string<)
+ :to-equal '("intro" "other"))))
+
+ (it "returns the id at point on a usage and on an anchor"
+ (adoc-test--with-xref-doc
+ (search-forward "<<intro")
+ (expect (xref-backend-identifier-at-point 'adoc) :to-equal "intro")
+ (goto-char (point-min))
+ (search-forward "[[intro")
+ (expect (xref-backend-identifier-at-point 'adoc) :to-equal "intro")))
+
+ (it "finds the anchor as the definition"
+ (adoc-test--with-xref-doc
+ (let ((defs (xref-backend-definitions 'adoc "intro")))
+ (expect (length defs) :to-equal 1)
+ (expect (xref-item-summary (car defs)) :to-match "\\[\\[intro\\]\\]"))))
+
+ (it "finds every usage as a reference, but not the definition"
+ (adoc-test--with-xref-doc
+ (let ((refs (xref-backend-references 'adoc "intro")))
+ ;; <<intro>>, <<intro,the intro>>, xref:intro[here]
+ (expect (length refs) :to-equal 3)
+ (dolist (r refs)
+ (expect (xref-item-summary r) :not :to-match "\\`\\[\\[")))))
+
+ (it "matches anchors by pattern via apropos"
+ (adoc-test--with-xref-doc
+ (expect (length (xref-backend-apropos 'adoc "intro")) :to-equal 1)))
+
+ (it "ignores whitespace inside an xref id"
+ (with-temp-buffer
+ (insert "[[foo]]\nSee <<foo >> and <<foo ,Cap>>.\n")
+ (adoc-mode)
+ (goto-char (point-min))
+ (search-forward "<<foo ")
+ ;; the id has no trailing space
+ (expect (xref-backend-identifier-at-point 'adoc) :to-equal "foo")
+ ;; and the whitespace-tolerant references are still found
+ (expect (length (xref-backend-references 'adoc "foo")) :to-equal 2)
+ (expect (length (xref-backend-definitions 'adoc "foo")) :to-equal 1)))
+
+ (it "does not confuse an id with a longer one sharing its prefix"
+ (with-temp-buffer
+ (insert "[[foo]]\n[[foobar]]\n<<foo>> <<foobar>> xref:foobar[x]\n")
+ (adoc-mode)
+ (expect (length (xref-backend-definitions 'adoc "foo")) :to-equal 1)
+ (expect (length (xref-backend-references 'adoc "foo")) :to-equal 1))))
+
;;; adoc-mode-navigation-test.el ends here