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