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

Reply via email to