branch: elpa/adoc-mode
commit 5e8aaeeba4b666ccc86a5edb8e0eb5939d9fbb69
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
Add context-aware completion-at-point
Bring AsciiDoc editing closer to other tooling by completing the things you
otherwise have to remember and type by hand. A single completion-at-point
function dispatches on the construct at point: cross-reference ids inside
<< and xref: (from the explicit anchors in the buffer), attribute names
inside {} (buffer :name: definitions plus common built-ins), file paths
after include::, and source-block languages inside [source,]. It works with
corfu, company, and built-in completion, and stays quiet in prose.
Anchor and attribute candidates reuse the existing adoc-re-anchor and
adoc-re-attribute-entry parsers.
---
CHANGELOG.md | 1 +
README.adoc | 18 ++++
adoc-mode.el | 184 ++++++++++++++++++++++++++++++++++++++
test/adoc-mode-completion-test.el | 146 ++++++++++++++++++++++++++++++
4 files changed, 349 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab6fd0766c..a86f8e44b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### New features
- 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 [...]
### Changes
diff --git a/README.adoc b/README.adoc
index 883fdac15c..a053c8f1b2 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, `include::` macros, and
xrefs at point (`C-c C-o` / `M-.`)
+- 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
- preview and export via Asciidoctor (`C-c C-c`): live HTML preview in an
xwidget or `eww` side pane, plus export to HTML, DocBook, PDF, and EPUB with
navigable warnings and errors
@@ -110,6 +111,23 @@ from the _AsciiDoc_ menu in the menu bar.
This section only describes some not so obvious features.
+=== Completion
+
+`adoc-mode` registers a `completion-at-point` function, so kbd:[M-TAB] (or a
+completion UI like https://github.com/minad/corfu[Corfu] or
+https://github.com/company-mode/company-mode[Company]) offers candidates based
+on the construct at point:
+
+- inside `<<` or `xref:` - cross-reference ids, taken from the explicit anchors
+ defined in the buffer (`[[id]]`, `[#id]`, `[[[biblio]]]`)
+- inside `{` - attribute names, both the ones defined with `:name:` in the
+ buffer and a set of common built-in attributes
+- after `include::` - file paths, relative to the document
+- inside `[source,` - source-block language names
+
+In plain prose the completion function yields to whatever else you have on
+`completion-at-point-functions`.
+
=== Preview and Export
`adoc-mode` can render and export documents by shelling out to the
diff --git a/adoc-mode.el b/adoc-mode.el
index 6e49a25ea2..b494e13b32 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -3503,6 +3503,187 @@ and title's text are not preserved, afterwards its
always one space."
(forward-line -1))
(move-to-column saved-col))))
+;;;; Completion
+
+(defconst adoc-intrinsic-attributes
+ '("doctitle" "author" "authorinitials" "firstname" "middlename" "lastname"
+ "email" "revnumber" "revdate" "revremark" "docdate" "doctime" "docdatetime"
+ "localdate" "localtime" "localdatetime" "sectnums" "sectnumlevels"
+ "sectlinks" "sectanchors" "toc" "toclevels" "toc-title" "icons" "iconfont"
+ "experimental" "nofooter" "noheader" "source-highlighter" "imagesdir"
+ "iconsdir" "stylesheet" "stylesdir" "linkcss" "data-uri" "idprefix"
+ "idseparator" "leveloffset" "tabsize" "version-label" "lang" "encoding")
+ "Common intrinsic AsciiDoc attribute names offered for completion.
+These supplement the attributes actually defined in the buffer.")
+
+(defconst adoc--completion-common-langs
+ '("c" "clojure" "cpp" "csharp" "css" "diff" "elixir" "emacs-lisp" "erlang"
+ "go" "groovy" "haskell" "html" "java" "javascript" "json" "kotlin" "lua"
+ "ocaml" "perl" "php" "python" "ruby" "rust" "scala" "sh" "shell" "sql"
+ "swift" "toml" "typescript" "xml" "yaml")
+ "Common source-block language names offered for completion.")
+
+(defun adoc--collect-anchor-ids ()
+ "Return a list of the explicit anchor ids defined in the buffer.
+Scans for block ids (`[[id]]', `[#id]'), inline anchors
+\(`[[id,reftext]]') and bibliography anchors (`[[[ref]]]'). Computed
+section auto-ids are intentionally not included."
+ (let ((ids '()))
+ (save-excursion
+ (save-match-data
+ ;; block-id and block-id-shorthand expose the bare id in group 1.
+ (dolist (type '(block-id block-id-shorthand))
+ (goto-char (point-min))
+ (let ((re (adoc-re-anchor type)))
+ (while (re-search-forward re nil t)
+ (push (match-string-no-properties 1) ids))))
+ ;; inline-special is `[[id,reftext]]'; group 2 is `id,reftext'.
+ (goto-char (point-min))
+ (let ((re (adoc-re-anchor 'inline-special)))
+ (while (re-search-forward re nil t)
+ (let ((attrlist (match-string-no-properties 2)))
+ (push (car (split-string attrlist "[ \t,]" t)) ids))))
+ ;; biblio is `[[[ref]]]'; group 2 is `[ref]'.
+ (goto-char (point-min))
+ (let ((re (adoc-re-anchor 'biblio)))
+ (while (re-search-forward re nil t)
+ (push (string-trim (match-string-no-properties 2) "\\[" "\\]")
+ ids)))))
+ (delete-dups (delq nil ids))))
+
+(defun adoc--collect-attribute-names ()
+ "Return attribute names for completion.
+The union of the attributes defined in the buffer (`:name:' entries)
+and `adoc-intrinsic-attributes'."
+ (let ((names (copy-sequence adoc-intrinsic-attributes)))
+ (save-excursion
+ (save-match-data
+ (goto-char (point-min))
+ (let ((re (adoc-re-attribute-entry)))
+ (while (re-search-forward re nil t)
+ ;; Group 1 is `:name[.subname]:' (optionally `:!name:' to unset).
+ ;; The bare name is the first run of id chars after the leading
+ ;; colon, stopping before any `.subname' or the closing colon.
+ (let ((raw (match-string-no-properties 1)))
+ (when (string-match ":!?\\([-a-zA-Z0-9_]+\\)" raw)
+ (push (match-string 1 raw) names)))))))
+ (delete-dups names)))
+
+(defun adoc--completion-langs ()
+ "Return the source-block language names offered for completion."
+ (delete-dups
+ (append (mapcar #'car adoc-code-lang-modes)
+ adoc--completion-common-langs)))
+
+(defun adoc--completion-token-bounds (&optional chars)
+ "Return the bounds (START . END) of the token around point.
+CHARS is the `skip-chars-backward' set delimiting the token; it
+defaults to the id characters allowed in attribute names and the like."
+ (save-excursion
+ (let ((end (point))
+ (start (progn (skip-chars-backward (or chars "-a-zA-Z0-9_"))
+ (point))))
+ (cons start end))))
+
+(defun adoc--completion-xref-bounds ()
+ "Return token bounds when point is inside an unclosed xref reference.
+Handles both the `<<id' and `xref:id' forms. Returns nil otherwise."
+ (let ((line-start (line-beginning-position))
+ (orig (point)))
+ (save-excursion
+ (when (or
+ ;; `<<' not yet closed by `>>', point before any `,'.
+ (save-excursion
+ (and (re-search-backward "<<" line-start t)
+ (goto-char (match-end 0))
+ (not (re-search-forward "\\(>>\\|,\\)" orig t))))
+ ;; `xref:' target, point before the `[' or `,'.
+ (save-excursion
+ (and (re-search-backward "xref:" line-start t)
+ (goto-char (match-end 0))
+ (not (re-search-forward "[][,]" orig t)))))
+ ;; Anchor ids may contain `.' (biblio / inline anchors), so include
+ ;; it in the token even though `adoc-re-id' itself is narrower.
+ (adoc--completion-token-bounds "-a-zA-Z0-9_.")))))
+
+(defun adoc--completion-attribute-bounds ()
+ "Return token bounds when point is inside an unclosed `{' reference.
+Returns nil otherwise."
+ (let ((line-start (line-beginning-position))
+ (orig (point)))
+ (save-excursion
+ (when (save-excursion
+ (and (re-search-backward "{" line-start t)
+ (goto-char (match-end 0))
+ (not (re-search-forward "[{}]" orig t))))
+ (adoc--completion-token-bounds)))))
+
+(defun adoc--completion-include-bounds ()
+ "Return path bounds when point is inside an `include::' target.
+Returns nil otherwise."
+ (save-excursion
+ (let ((bol (line-beginning-position))
+ (pos (point)))
+ (goto-char bol)
+ (when (and (looking-at "include1?::")
+ (<= (match-end 0) pos))
+ (let ((start (match-end 0)))
+ (goto-char start)
+ (unless (re-search-forward "\\[" pos t)
+ (cons start pos)))))))
+
+(defun adoc--completion-source-lang-bounds ()
+ "Return language-token bounds when point is in a `[source,LANG' field.
+Returns nil otherwise."
+ (save-excursion
+ (let ((bol (line-beginning-position))
+ (pos (point)))
+ (goto-char bol)
+ (when (and (looking-at "\\[source,[ \t]*")
+ (<= (match-end 0) pos))
+ (let ((start (match-end 0)))
+ (goto-char start)
+ (unless (re-search-forward "[],]" pos t)
+ (cons start pos)))))))
+
+(defun adoc-completion-at-point ()
+ "Complete the AsciiDoc construct at point.
+A `completion-at-point-functions' entry that offers candidates for
+cross-reference / anchor ids inside `<<' and `xref:', attribute names
+inside `{', file paths after `include::', and source-block languages
+inside `[source,'."
+ (let (bounds)
+ (cond
+ ;; An unclosed `{' means an attribute reference, even when it sits inside
+ ;; an xref target or a `[source,' field, so check it first.
+ ((setq bounds (adoc--completion-attribute-bounds))
+ (list (car bounds) (cdr bounds)
+ (completion-table-dynamic
+ (lambda (_) (adoc--collect-attribute-names)))
+ :annotation-function (lambda (_) " attribute")
+ :company-kind (lambda (_) 'variable)
+ :exclusive 'no))
+ ((setq bounds (adoc--completion-xref-bounds))
+ (list (car bounds) (cdr bounds)
+ (completion-table-dynamic
+ (lambda (_) (adoc--collect-anchor-ids)))
+ :annotation-function (lambda (_) " anchor")
+ :company-kind (lambda (_) 'reference)
+ :exclusive 'no))
+ ((setq bounds (adoc--completion-source-lang-bounds))
+ (list (car bounds) (cdr bounds)
+ (completion-table-dynamic
+ (lambda (_) (adoc--completion-langs)))
+ :annotation-function (lambda (_) " lang")
+ :company-kind (lambda (_) 'enum)
+ :exclusive 'no))
+ ((setq bounds (adoc--completion-include-bounds))
+ (list (car bounds) (cdr bounds)
+ #'completion-file-name-table
+ :annotation-function (lambda (_) " file")
+ :company-kind (lambda (_) 'file)
+ :exclusive 'no)))))
+
;;;; Heading navigation
(defun adoc--re-all-titles ()
@@ -4315,6 +4496,9 @@ Turning on Adoc mode runs the normal hook
`adoc-mode-hook'."
(add-hook 'fill-nobreak-predicate #'adoc-fill-nobreak-p nil t)
(setq-local fill-paragraph-function #'adoc-fill-paragraph)
+ ;; completion
+ (add-hook 'completion-at-point-functions #'adoc-completion-at-point nil t)
+
;; misc
(setq-local page-delimiter "^<<<+$")
(setq-local require-final-newline mode-require-final-newline)
diff --git a/test/adoc-mode-completion-test.el
b/test/adoc-mode-completion-test.el
new file mode 100644
index 0000000000..77bad0f76d
--- /dev/null
+++ b/test/adoc-mode-completion-test.el
@@ -0,0 +1,146 @@
+;;; adoc-mode-completion-test.el --- Completion-at-point tests -*-
lexical-binding: t; -*-
+
+;; Copyright © 2026 Bozhidar Batsov
+
+;;; Commentary:
+
+;; Buttercup tests for `adoc-completion-at-point' and its helpers: the anchor
+;; and attribute candidate collectors, and the per-context detectors that pick
+;; the right completion (xref/anchor ids, attribute names, include paths,
+;; source-block languages) based on the construct at point.
+
+;;; Code:
+
+(require 'adoc-mode-test-helpers)
+
+(defun adoc-test--capf-at (content)
+ "Return a description of `adoc-completion-at-point' in CONTENT.
+Point is placed where the literal `|' marker appears in CONTENT (the
+marker is removed first). Returns nil when no completion is offered,
+otherwise a plist with `:start', `:end', `:collection', and the resolved
+`:candidates' (computed while the buffer is still live, since the
+collection scans it lazily)."
+ (with-temp-buffer
+ (insert (adoc-test--dedent content))
+ (goto-char (point-min))
+ (let ((pos (when (search-forward "|" nil t)
+ (delete-char -1)
+ (point))))
+ (adoc-mode)
+ (when pos (goto-char pos))
+ (let ((capf (adoc-completion-at-point)))
+ (when capf
+ (list :start (nth 0 capf)
+ :end (nth 1 capf)
+ :collection (nth 2 capf)
+ :token (buffer-substring-no-properties (nth 0 capf) (nth 1
capf))
+ :candidates (all-completions "" (nth 2 capf))))))))
+
+(defun adoc-test--capf-candidates (capf)
+ "Return the resolved candidate strings of CAPF (a `adoc-test--capf-at'
plist)."
+ (plist-get capf :candidates))
+
+(describe "adoc--collect-anchor-ids"
+ (it "collects ids from every explicit anchor form"
+ (with-adoc-buffer "
+ [[alpha]]
+ [[beta,Beta Text]]
+ [#gamma]
+ [source#delta]
+ [[[biblio1]]]
+ "
+ (expect (sort (adoc--collect-anchor-ids) #'string<)
+ :to-equal '("alpha" "beta" "biblio1" "delta" "gamma"))))
+
+ (it "returns nil when there are no anchors"
+ (with-adoc-buffer "Just some prose with <<a-ref>> but no definitions.\n"
+ (expect (adoc--collect-anchor-ids) :to-equal nil))))
+
+(describe "adoc--collect-attribute-names"
+ (it "includes buffer-defined attributes and strips subnames"
+ (with-adoc-buffer ":my-attr: value\n:other.sub.deep: w\n:flag!:\n"
+ (let ((names (adoc--collect-attribute-names)))
+ (expect (member "my-attr" names) :to-be-truthy)
+ ;; multi-segment subnames are stripped to the bare name
+ (expect (member "other" names) :to-be-truthy)
+ (expect (member "other.sub" names) :to-be nil)
+ ;; a trailing unset `!' is not part of the name
+ (expect (member "flag" names) :to-be-truthy)
+ (expect (member "flag!" names) :to-be nil))))
+
+ (it "includes curated intrinsic attributes"
+ (with-adoc-buffer "no attributes here\n"
+ (let ((names (adoc--collect-attribute-names)))
+ (expect (member "toc" names) :to-be-truthy)
+ (expect (member "doctitle" names) :to-be-truthy)))))
+
+(describe "adoc-completion-at-point"
+ (describe "xref / anchor context"
+ (it "completes inside an unclosed <<"
+ (let ((capf (adoc-test--capf-at "[[alpha]]\n\nSee <<al|")))
+ (expect capf :to-be-truthy)
+ (expect (member "alpha" (adoc-test--capf-candidates capf))
+ :to-be-truthy)))
+
+ (it "completes inside an xref: target"
+ (let ((capf (adoc-test--capf-at "[[alpha]]\n\nxref:al|")))
+ (expect capf :to-be-truthy)
+ (expect (member "alpha" (adoc-test--capf-candidates capf))
+ :to-be-truthy)))
+
+ (it "does not complete after a closed <<id>>"
+ (expect (adoc-test--capf-at "See <<id>>|") :to-be nil))
+
+ (it "covers a dotted id so the candidate matches"
+ (let ((capf (adoc-test--capf-at "[[a.b]]\n\nSee <<a.b|")))
+ (expect capf :to-be-truthy)
+ (expect (member "a.b" (adoc-test--capf-candidates capf))
+ :to-be-truthy)
+ ;; the whole dotted id is the completion token, not just `b'
+ (expect (plist-get capf :token) :to-equal "a.b"))))
+
+ (describe "attribute context"
+ (it "completes inside an unclosed {"
+ (let ((capf (adoc-test--capf-at ":my-attr: v\n\nUse {my|")))
+ (expect capf :to-be-truthy)
+ (expect (member "my-attr" (adoc-test--capf-candidates capf))
+ :to-be-truthy)))
+
+ (it "does not complete after a closed {attr}"
+ (expect (adoc-test--capf-at "{my-attr} text|") :to-be nil))
+
+ (it "wins over xref when a { is open inside <<"
+ (let ((capf (adoc-test--capf-at ":my-attr: v\n\nSee <<{my|")))
+ (expect (member "my-attr" (adoc-test--capf-candidates capf))
+ :to-be-truthy)))
+
+ (it "wins over the source-language context when a { is open"
+ (let ((capf (adoc-test--capf-at ":my-attr: v\n\n[source,{my|")))
+ (expect (member "my-attr" (adoc-test--capf-candidates capf))
+ :to-be-truthy))))
+
+ (describe "source language context"
+ (it "completes the language inside [source,"
+ (let ((capf (adoc-test--capf-at "[source,ru|")))
+ (expect capf :to-be-truthy)
+ (expect (member "ruby" (adoc-test--capf-candidates capf))
+ :to-be-truthy)))
+
+ (it "does not complete after the closing bracket"
+ (expect (adoc-test--capf-at "[source,ruby]|") :to-be nil)))
+
+ (describe "include context"
+ (it "offers file completion after include::"
+ (let ((capf (adoc-test--capf-at "include::fo|")))
+ (expect capf :to-be-truthy)
+ (expect (plist-get capf :collection) :to-be
#'completion-file-name-table)))
+
+ (it "does not complete once the attribute list is open"
+ (expect (adoc-test--capf-at "include::f.adoc[|") :to-be nil)))
+
+ (it "returns nil in plain prose"
+ (expect (adoc-test--capf-at "just some ordinary prose here|") :to-be nil)))
+
+(provide 'adoc-mode-completion-test)
+
+;;; adoc-mode-completion-test.el ends here