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

Reply via email to