branch: elpa/adoc-mode
commit f82695ff7405aa993cfb223fe9be44091749b927
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    Make references clickable for easier navigation
    
    Attach a follow keymap, hover highlight, and help-echo to cross-references
    (<<id>>, xref:id[]), links/URLs (link:, https:, ...), and include:: macros
    during fontification, so a mouse-1 or mouse-2 click follows them via the
    existing adoc-follow-thing-at-point. The keymap is skipped inside code and
    literal blocks, where those constructs aren't real references.
    
    Along the way, teach adoc-follow-thing-at-point to follow link: macros
    (local file or URL) and to drop the [label] when opening a URL macro, and
    turn the missing-anchor error into a user-error.
---
 CHANGELOG.md                      |  1 +
 README.adoc                       |  2 +-
 adoc-mode.el                      | 65 +++++++++++++++++++++++++++++++++++----
 test/adoc-mode-navigation-test.el | 64 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 125 insertions(+), 7 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5fb267ffd..7adddace06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 - Add Asciidoctor integration for previewing and exporting documents, 
reachable from the new `adoc-asciidoctor-menu` transient on `C-c C-c` (and the 
AsciiDoc menu). `adoc-preview` renders the current buffer with `asciidoctor` 
and shows the HTML in a side pane - an xwidget WebKit widget when available, 
otherwise `eww`, configurable via `adoc-preview-backend` - and 
`adoc-live-preview-mode` re-renders on every save. The preview feeds the buffer 
to `asciidoctor` through its standard input, s [...]
 - 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.
 
 ### Changes
 
diff --git a/README.adoc b/README.adoc
index 5c3de447e9..1002705428 100644
--- a/README.adoc
+++ b/README.adoc
@@ -48,7 +48,7 @@ Here are some of the main features of `adoc-mode`:
 - heading navigation modelled on `markdown-mode` / `org-mode`: next / previous 
heading (`C-c C-n` / `C-c C-p`), forward / backward at the same level (`C-c 
C-f` / `C-c C-b`), and up to the parent heading (`C-c C-u`)
 - 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, `include::` macros, and 
xrefs at point (`C-c C-o` / `M-.`)
+- 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
 - 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 e98855061c..a85402d6e4 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -2068,6 +2068,7 @@ TEXTPROPS is an additional plist with textproperties."
   (let ((cmd-name (regexp-opt '("http" "https" "ftp" "file" "irc" "mailto" 
"callto" "link"))))
     (list
      `(lambda (end) (adoc-kwf-std end ,(adoc-re-inline-macro cmd-name) '(0) 
'(0)))
+     '(0 '(face nil keymap adoc-link-keymap mouse-face highlight help-echo 
"mouse-1: visit this link")) ; clickable
      `(1 '(face adoc-url-face adoc-reserved t adoc-flyspell-ignore t) t) ; 
cmd-name
      `(2 '(face adoc-url-face adoc-reserved t) t) ; :
      `(3 '(face adoc-url-face adoc-reserved t adoc-flyspell-ignore t) t) ; 
target
@@ -2079,6 +2080,7 @@ TEXTPROPS is an additional plist with textproperties."
   (let ((cmd-name (regexp-opt '("http" "https" "ftp" "file" "irc" "mailto" 
"callto" "link"))))
     (list
      `(lambda (end) (adoc-kwf-std end ,(adoc-re-inline-macro cmd-name nil nil 
'empty) '(0) '(0)))
+     '(0 '(face nil keymap adoc-link-keymap mouse-face highlight help-echo 
"mouse-1: visit this link")) ; clickable
      '(1 '(face adoc-url-face adoc-reserved t adoc-flyspell-ignore t) append) 
; cmd-name
      '(2 '(face adoc-url-face adoc-reserved t) append)               ; :
      '(3 '(face adoc-url-face adoc-reserved t adoc-flyspell-ignore t) append)  
             ; target
@@ -2122,7 +2124,7 @@ TEXTPROPS is an additional plist with textproperties."
          (both (concat "\\(?:" url "\\)\\|\\(?:" url<> "\\)\\|\\(?:" email 
"\\)")))
     (list
      `(lambda (end) (adoc-kwf-std end ,both '(0) '(0)))
-     '(0 '(face adoc-url-face adoc-reserved t adoc-flyspell-ignore t) append 
t))))
+     '(0 '(face adoc-url-face adoc-reserved t adoc-flyspell-ignore t keymap 
adoc-link-keymap mouse-face highlight help-echo "mouse-1: visit this URL") 
append t))))
 
 ;; bug: escapes are not handled yet
 ;; TODO: give the inserted character a specific face. But I fear that is not
@@ -2476,7 +2478,7 @@ for multiline constructs to be matched."
          '(4 '(face adoc-meta-hide-face adoc-reserved block-del))) ; ]
    ;; include
    (list "^\\(\\(include1?::\\)\\([^ \t\n]*?\\)\\(\\[\\)\\(.*?\\)\\(\\]\\)\\)[ 
\t]*$"
-         '(1 '(face nil adoc-reserved block-del)) ; the whole match
+         '(1 '(face nil adoc-reserved block-del keymap adoc-link-keymap 
mouse-face highlight help-echo "mouse-1: open this include")) ; the whole match
          '(2 '(face adoc-preprocessor-face adoc-flyspell-ignore t))      ; 
macro name
          '(3 '(face adoc-meta-face adoc-flyspell-ignore t))              ; 
file name
          '(4 'adoc-meta-hide-face)         ; [
@@ -2765,7 +2767,8 @@ for multiline constructs to be matched."
    (adoc-kw-inline-macro-urls-attribute-list)
    (adoc-kw-inline-macro "anchor" nil nil nil 'adoc-anchor-face t 
'("xreflabel"))
    (adoc-kw-inline-macro "xref" nil nil nil '(adoc-reference-face 
adoc-internal-reference-face) t
-                         '(("caption") (("caption" . adoc-reference-face))))
+                         '(("caption") (("caption" . adoc-reference-face)))
+                         '(keymap adoc-link-keymap mouse-face highlight 
help-echo "mouse-1: jump to this anchor"))
    (adoc-kw-inline-macro "footnote" t nil 'adoc-footnote-marker-face nil nil 
'adoc-footnote-text-face)
    (adoc-kw-inline-macro "footnoteref" t 'single-attribute 
'adoc-footnote-marker-face nil nil
                          '(("id") (("id" . adoc-internal-reference-face))))
@@ -2808,6 +2811,9 @@ for multiline constructs to be matched."
    ;; see also xref: within inline macros
    ;; reference with own/explicit caption
    (list (adoc-re-xref 'inline-special-with-caption t)
+         ;; clickable, but not inside code/literal blocks (those set 
adoc-reserved)
+         '(0 (unless (get-text-property (match-beginning 0) 'adoc-reserved)
+               '(face nil keymap adoc-link-keymap mouse-face highlight 
help-echo "mouse-1: jump to this anchor")))
          '(1 'adoc-meta-hide-face)       ; <<
          '(2 'adoc-meta-face)            ; anchor-id
          '(3 'adoc-meta-hide-face)       ; ,
@@ -2815,6 +2821,8 @@ for multiline constructs to be matched."
          '(5 'adoc-meta-hide-face))      ; >>
    ;; reference without caption
    (list (adoc-re-xref 'inline-special-no-caption t)
+         '(0 (unless (get-text-property (match-beginning 0) 'adoc-reserved)
+               '(face nil keymap adoc-link-keymap mouse-face highlight 
help-echo "mouse-1: jump to this anchor")))
          '(1 'adoc-meta-hide-face)       ; <<
          '(2 'adoc-reference-face)       ; link text = anchor id
          '(3 'adoc-meta-hide-face))      ; >>
@@ -2906,13 +2914,37 @@ for multiline constructs to be matched."
   (let ((pos (save-excursion
                (goto-char (point-min))
                (re-search-forward (adoc-re-anchor nil id) nil t))))
-    (if (null pos) (error (concat "Can't find an anchor defining '" id "'")))
+    (if (null pos) (user-error "Can't find an anchor defining '%s'" id))
     (push-mark)
     (goto-char pos)))
 
+(defun adoc--inline-link-at-point ()
+  "Return the target of an inline link or URL macro covering point, or nil.
+Handles `link:target[...]' and `scheme:target[...]' (for the http,
+https, ftp, file, irc, mailto and callto schemes).  The result is the
+string to open: the bare target for `link:', or `scheme:target' for the
+URL schemes (the attribute list / label is dropped)."
+  (save-excursion
+    (let ((pos (point))
+          (eol (line-end-position))
+          (re (adoc-re-inline-macro
+               (regexp-opt '("http" "https" "ftp" "file" "irc" "mailto"
+                             "callto" "link")))))
+      (beginning-of-line)
+      (catch 'found
+        (while (re-search-forward re eol t)
+          (when (and (<= (match-beginning 0) pos) (<= pos (match-end 0)))
+            (let ((cmd (match-string-no-properties 1))
+                  (target (match-string-no-properties 3)))
+              (throw 'found
+                     (if (string= cmd "link")
+                         target
+                       (concat cmd ":" target))))))
+        nil))))
+
 (defun adoc-follow-thing-at-point ()
   "Follow the link or reference at point.
-When point is on a URL, open it in a browser.
+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."
   (interactive)
@@ -2928,12 +2960,33 @@ When point is on an xref or cross-reference, jump to 
its anchor."
    ;; xref at point — jump to anchor
    ((adoc-xref-id-at-point)
     (adoc-goto-ref-label (adoc-xref-id-at-point)))
-   ;; URL at point — open in browser
+   ;; link:/URL inline macro — follow the target, ignoring the label
+   ((adoc--inline-link-at-point)
+    (let ((target (adoc--inline-link-at-point)))
+      (cond
+       ((string-match-p
+         "\\`\\(?:https?\\|ftp\\|file\\|irc\\|mailto\\|callto\\):" target)
+        (browse-url target))
+       ((file-exists-p target) (find-file target))
+       (t (user-error "File not found: %s" target)))))
+   ;; bare URL at point — open in browser
    ((thing-at-point 'url)
     (browse-url (thing-at-point 'url t)))
    (t
     (user-error "Nothing to follow at point"))))
 
+(defvar adoc-link-keymap
+  (let ((map (make-sparse-keymap)))
+    (define-key map [mouse-2] #'adoc-follow-thing-at-point)
+    (define-key map [follow-link] 'mouse-face)
+    map)
+  "Keymap on clickable references (xrefs, links, URLs, `include::').
+A quick `mouse-1' or a `mouse-2' click follows the reference via
+`adoc-follow-thing-at-point'.  It is attached to those constructs as a
+`keymap' text property during fontification.")
+;; Make the symbol usable as the value of a `keymap' text property.
+(fset 'adoc-link-keymap adoc-link-keymap)
+
 (defun adoc-promote (&optional arg)
   "Promote the structure at point ARG levels.
 
diff --git a/test/adoc-mode-navigation-test.el 
b/test/adoc-mode-navigation-test.el
index 0b7cbbc27c..dd83314367 100644
--- a/test/adoc-mode-navigation-test.el
+++ b/test/adoc-mode-navigation-test.el
@@ -310,4 +310,68 @@
       (re-search-backward "bli")
       (expect (adoc-xref-id-at-point) :to-equal "foo"))))
 
+(describe "clickable references"
+  (defun adoc-test--prop-at (content needle prop)
+    "Fontify CONTENT and return text property PROP at the start of NEEDLE."
+    (with-temp-buffer
+      (insert content)
+      (adoc-mode)
+      (font-lock-ensure)
+      (goto-char (point-min))
+      (and (search-forward needle nil t)
+           (get-text-property (match-beginning 0) prop))))
+
+  (it "marks every reference construct with the link keymap"
+    (let ((doc (concat "[[target]]\n= Doc\n\n"
+                       "See <<target>> and <<target,the target>>.\n\n"
+                       "Also xref:target[here] and 
https://example.com[site].\n\n";
+                       "A bare https://bare.example.com link.\n\n"
+                       "include::other.adoc[]\n")))
+      (dolist (needle '("<<target>>" "<<target,the target>>" 
"xref:target[here]"
+                        "https://example.com[site]"; "bare.example.com"
+                        "include::other.adoc[]"))
+        (expect (adoc-test--prop-at doc needle 'keymap) :to-be 
'adoc-link-keymap)
+        (expect (adoc-test--prop-at doc needle 'mouse-face) :to-be 'highlight)
+        (expect (adoc-test--prop-at doc needle 'help-echo) :to-be-truthy))))
+
+  (it "leaves plain prose unclickable"
+    (expect (adoc-test--prop-at "just some ordinary prose\n" "ordinary" 
'keymap)
+            :to-be nil))
+
+  (it "does not make an xref inside a code block clickable"
+    (expect (adoc-test--prop-at "----\n<<dead>>\n----\n" "<<dead>>" 'keymap)
+            :to-be nil)
+    ;; but the same xref outside the block is clickable
+    (expect (adoc-test--prop-at "<<live>>\n\n----\ncode\n----\n" "<<live>>" 
'keymap)
+            :to-be 'adoc-link-keymap))
+
+  (it "binds mouse-2 and follow-link in the link keymap"
+    (expect (lookup-key adoc-link-keymap [mouse-2])
+            :to-be #'adoc-follow-thing-at-point)
+    (expect (lookup-key adoc-link-keymap [follow-link]) :to-be 'mouse-face)))
+
+(describe "adoc--inline-link-at-point"
+  (it "extracts a URL macro target without its label"
+    (with-temp-buffer
+      (adoc-mode)
+      (insert "see https://example.com[Example] ok")
+      (goto-char (point-min))
+      (search-forward "example")
+      (expect (adoc--inline-link-at-point) :to-equal "https://example.com";)))
+
+  (it "extracts a link: macro target"
+    (with-temp-buffer
+      (adoc-mode)
+      (insert "see link:other.adoc[the doc] ok")
+      (goto-char (point-min))
+      (search-forward "other")
+      (expect (adoc--inline-link-at-point) :to-equal "other.adoc")))
+
+  (it "returns nil away from any link macro"
+    (with-temp-buffer
+      (adoc-mode)
+      (insert "just prose")
+      (goto-char (point-min))
+      (expect (adoc--inline-link-at-point) :to-be nil))))
+
 ;;; adoc-mode-navigation-test.el ends here

Reply via email to