diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b9f19..6440167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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 (`<>`, `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 `<>` / `xref:id[]` usages, resolved within the current buffer. `M-.` keeps following URLs and `include::` too, via `adoc-follow-thing-at-point`. +- Treat section titles as cross-reference targets. `adoc-mode` now derives each section's auto-id the way Asciidoctor does, so completion (`<<` / `xref:`), the `xref` backend, and `adoc-goto-ref-label` offer and resolve section ids - not just explicit anchors. The id style is detected automatically: a document's own `:idprefix:` / `:idseparator:` win, otherwise files inside an Antora component (an `antora.yml` above them) use Antora's kebab-case style (`My Title` -> `my-title`) and everything else uses Asciidoctor's default (`_my_title`). The new `adoc-section-id-style` option forces a specific style. ### Changes diff --git a/README.adoc b/README.adoc index 81fd859..37e90e0 100644 --- a/README.adoc +++ b/README.adoc @@ -48,8 +48,9 @@ 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 with completion over the buffer's 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 +- navigate to anchors and sections with completion (`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 +- section titles act as cross-reference targets too: their Asciidoctor auto-ids are offered in completion and resolved by navigation, with the id style (Asciidoctor `_my_title` vs Antora `my-title`) detected automatically or set via `adoc-section-id-style` +- an `xref` backend over anchors and sections: `M-?` lists every cross-reference to the id 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 45ac684..dd3fe4d 100644 --- a/adoc-mode.el +++ b/adoc-mode.el @@ -228,6 +228,27 @@ delimited block lines have a certain length." number) :group 'adoc) +(defcustom adoc-section-id-style 'auto + "How section auto-ids are derived from section titles. + +Asciidoctor turns a section title into an id using the `idprefix' and +`idseparator' attributes. This option controls which style `adoc-mode' +assumes when offering and resolving section ids (completion, the `xref' +backend, `adoc-goto-ref-label'). + +`auto' Honour `:idprefix:' / `:idseparator:' set in the document; + otherwise use the Antora style when the file lives in an + Antora component (an `antora.yml' is found above it), and + the Asciidoctor default style elsewhere. +`asciidoctor' Asciidoctor's default: prefix and separator both `_' + (e.g. `My Title' -> `_my_title'). +`antora' Antora's default: empty prefix, `-' separator + (e.g. `My Title' -> `my-title')." + :type '(choice (const :tag "Auto-detect" auto) + (const :tag "Asciidoctor default (_my_title)" asciidoctor) + (const :tag "Antora (my-title)" antora)) + :group 'adoc) + (defcustom adoc-imenu-create-index-function 'adoc-imenu-create-nested-index "Function to create the imenu index. Use `adoc-imenu-create-nested-index' for a hierarchical index @@ -2909,15 +2930,19 @@ for multiline constructs to be matched." (interactive (let* ((default (adoc-xref-id-at-point)) (default-str (if default (concat "(default " default ")") ""))) (list - ;; Offer the buffer's anchors as candidates, but stay - ;; permissive (require-match nil) so a not-yet-defined id can - ;; still be entered. + ;; Offer the buffer's anchors and section ids as candidates, + ;; but stay permissive (require-match nil) so a not-yet-defined + ;; id can still be entered. (completing-read (concat "Goto anchor of reference/label " default-str ": ") - (adoc--collect-anchor-ids) nil nil nil nil default)))) - (let ((pos (save-excursion - (goto-char (point-min)) - (re-search-forward (adoc-re-anchor nil id) nil t)))) + (delete-dups (append (adoc--collect-anchor-ids) + (adoc--collect-section-ids))) + nil nil nil nil default)))) + (let ((pos (or (save-excursion + (goto-char (point-min)) + (re-search-forward (adoc-re-anchor nil id) nil t)) + ;; fall back to a section whose auto-id (or title) matches + (adoc--section-position id)))) (if (null pos) (user-error "Can't find an anchor defining '%s'" id)) (push-mark) (goto-char pos))) @@ -3580,6 +3605,130 @@ and title's text are not preserved, afterwards its always one space." (forward-line -1)) (move-to-column saved-col)))) +;;;; Section auto-ids + +;; Asciidoctor derives an id for every section from its title (unless +;; `sectids' is off). These ids are what cross-references point at in +;; practice - far more often than explicit `[[id]]' anchors - so we generate +;; them and feed them to completion, the `xref' backend and +;; `adoc-goto-ref-label'. + +(defun adoc--doc-attribute (name) + "Return the value of document attribute NAME, or nil when it is not set. +An attribute set to an empty value (e.g. `:idprefix:') returns the empty +string, which is distinct from nil." + (when buffer-file-name + (save-excursion + (save-match-data + (goto-char (point-min)) + (when (re-search-forward + (concat "^:" (regexp-quote name) ":[ \t]*\\(.*\\)$") nil t) + (string-trim-right (match-string-no-properties 1))))))) + +(defun adoc--antora-p () + "Return non-nil when the buffer's file lives in an Antora component." + (and buffer-file-name + (locate-dominating-file buffer-file-name "antora.yml") + t)) + +(defun adoc--section-id-params () + "Return (PREFIX . SEPARATOR) for section id generation in this buffer. +See `adoc-section-id-style'." + (pcase adoc-section-id-style + ('asciidoctor (cons "_" "_")) + ('antora (cons "" "-")) + (_ + (let ((prefix (adoc--doc-attribute "idprefix")) + (separator (adoc--doc-attribute "idseparator"))) + (cond + ;; The document sets the attributes explicitly; an unset one keeps + ;; Asciidoctor's `_' default. + ((or prefix separator) + (cons (or prefix "_") (or separator "_"))) + ((adoc--antora-p) (cons "" "-")) + (t (cons "_" "_"))))))) + +(defun adoc--section-id (title &optional prefix separator) + "Return the Asciidoctor auto-id for the section titled TITLE. +PREFIX and SEPARATOR default to those of `adoc--section-id-params'. +Mirrors Asciidoctor's id generation: downcase, drop characters outside +letters/digits/`_'/space/`.'/`-', translate runs of space, `.' and `-' +to the separator, strip a leading/trailing separator, then prepend the +prefix." + (let* ((params (unless (and prefix separator) (adoc--section-id-params))) + (prefix (or prefix (car params))) + (separator (or separator (cdr params))) + (id (downcase title))) + (setq id (replace-regexp-in-string "<[^>]*>" "" id)) ; inline tags + (setq id (replace-regexp-in-string "[^[:alnum:]_ .-]" "" id)) ; invalid chars + (if (string-empty-p separator) + ;; An empty separator only deletes spaces; `.' and `-' are kept. + (setq id (replace-regexp-in-string " +" "" id t t)) + ;; Collapse runs of space, `.', `-' AND the separator itself to a single + ;; separator (so e.g. `foo_ bar' -> `foo_bar', not `foo__bar'), then + ;; strip a leading/trailing separator. `-' is kept last in the class so + ;; it stays a literal rather than forming a range. + (let* ((extra (if (member separator '("." "-")) "" separator)) + (class (concat "[ ." extra "-]+"))) + (setq id (replace-regexp-in-string class separator id t t))) + (let ((q (regexp-quote separator))) + (setq id (replace-regexp-in-string + (concat "\\`\\(?:" q "\\)+\\|\\(?:" q "\\)+\\'") "" id)))) + (concat prefix id))) + +(defun adoc--collect-sections () + "Return a list of (ID TITLE POSITION) for the buffer's section titles. +Only headings that font-lock actually fontifies as titles are included, +so `==' lines inside code or other delimited blocks are skipped." + (save-excursion + (save-match-data + (font-lock-ensure) + (let ((re (adoc--re-all-titles)) + (params (adoc--section-id-params)) + (result '())) + (goto-char (point-min)) + (while (re-search-forward re nil t) + (goto-char (match-beginning 0)) + (let ((descriptor (adoc--heading-descriptor-at-point))) + (cond + ;; A level-0 title is the document title, not a referenceable + ;; section, so skip it (but advance past it). + ((and descriptor (= (nth 2 descriptor) 0)) + (goto-char (nth 5 descriptor))) + (descriptor + (let ((title (string-trim (nth 3 descriptor)))) + (push (list (adoc--section-id title (car params) (cdr params)) + title (nth 4 descriptor)) + result) + (goto-char (nth 5 descriptor)))) + (t (forward-line 1))))) + (nreverse result))))) + +(defun adoc--collect-section-ids () + "Return the auto-ids of the buffer's section titles." + (delete-dups (mapcar #'car (adoc--collect-sections)))) + +(defun adoc--section-position (id) + "Return the start position of the section matching ID, or nil. +A section matches when its auto-id equals ID or, for a natural +cross-reference, when its title does." + (seq-some (lambda (s) + (when (or (string= (nth 0 s) id) + (string= (nth 1 s) id)) + (nth 2 s))) + (adoc--collect-sections))) + +(defun adoc--section-definitions (id) + "Return a list of xref items for sections matching ID (auto-id or title)." + (let ((buffer (current-buffer))) + (delq nil + (mapcar (lambda (s) + (when (or (string= (nth 0 s) id) + (string= (nth 1 s) id)) + (xref-make (nth 1 s) + (xref-make-buffer-location buffer (nth 2 s))))) + (adoc--collect-sections))))) + ;;;; Completion (defconst adoc-intrinsic-attributes @@ -3743,7 +3892,8 @@ inside `[source,'." ((setq bounds (adoc--completion-xref-bounds)) (list (car bounds) (cdr bounds) (completion-table-dynamic - (lambda (_) (adoc--collect-anchor-ids))) + (lambda (_) (delete-dups (append (adoc--collect-anchor-ids) + (adoc--collect-section-ids))))) :annotation-function (lambda (_) " anchor") :company-kind (lambda (_) 'reference) :exclusive 'no)) @@ -3828,10 +3978,11 @@ the match." (adoc--anchor-id-at-point))) (cl-defmethod xref-backend-identifier-completion-table ((_backend (eql adoc))) - (adoc--collect-anchor-ids)) + (delete-dups (append (adoc--collect-anchor-ids) (adoc--collect-section-ids)))) (cl-defmethod xref-backend-definitions ((_backend (eql adoc)) identifier) - (adoc--xref-collect (adoc-re-anchor nil identifier))) + (append (adoc--xref-collect (adoc-re-anchor nil identifier)) + (adoc--section-definitions identifier))) (cl-defmethod xref-backend-references ((_backend (eql adoc)) identifier) (adoc--xref-collect (adoc--re-xref-to identifier))) diff --git a/test/adoc-mode-section-id-test.el b/test/adoc-mode-section-id-test.el new file mode 100644 index 0000000..d93b79a --- /dev/null +++ b/test/adoc-mode-section-id-test.el @@ -0,0 +1,145 @@ +;;; adoc-mode-section-id-test.el --- Section auto-id tests -*- lexical-binding: t; -*- + +;; Copyright © 2026 Bozhidar Batsov + +;;; Commentary: + +;; Buttercup tests for AsciiDoc section auto-ids: the id-generation algorithm +;; (validated against the real `asciidoctor' when available), id-style +;; detection (document attributes / Antora layout / `adoc-section-id-style'), +;; collecting section ids, and resolving them via the xref backend and +;; `adoc-goto-ref-label'. + +;;; Code: + +(require 'adoc-mode-test-helpers) +(require 'cl-lib) + +(describe "adoc--section-id" + (it "generates Asciidoctor-default (underscore) ids" + (expect (adoc--section-id "Clojure CLI Setup" "_" "_") + :to-equal "_clojure_cli_setup") + (expect (adoc--section-id "Hello, World!" "_" "_") :to-equal "_hello_world") + (expect (adoc--section-id "Foo & Bar (baz)" "_" "_") :to-equal "_foo_bar_baz") + (expect (adoc--section-id "Dots.in.title" "_" "_") :to-equal "_dots_in_title") + (expect (adoc--section-id "1. Numbered start" "_" "_") + :to-equal "_1_numbered_start") + (expect (adoc--section-id "snake_case_already" "_" "_") + :to-equal "_snake_case_already")) + + (it "collapses separator-adjacent runs to a single separator" + ;; a kept underscore next to a converted space must not double up + (expect (adoc--section-id "foo_ bar" "_" "_") :to-equal "_foo_bar") + (expect (adoc--section-id "a_-_b" "_" "_") :to-equal "_a_b")) + + (it "with an empty separator only removes spaces, keeping . and -" + (expect (adoc--section-id "a.b-c" "" "") :to-equal "a.b-c") + (expect (adoc--section-id "a b" "" "") :to-equal "ab")) + + (it "generates Antora-style (kebab) ids" + (expect (adoc--section-id "Clojure CLI Setup" "" "-") + :to-equal "clojure-cli-setup") + (expect (adoc--section-id "kebab-already-here" "" "-") + :to-equal "kebab-already-here") + (expect (adoc--section-id "snake_case_already" "" "-") + :to-equal "snake_case_already")) + + (it "matches the real asciidoctor for a range of titles" + (assume (executable-find "asciidoctor") "asciidoctor not installed") + (dolist (title '("Clojure CLI Setup" "Hello, World!" "C++ and C#" + "Trailing punctuation!!!" "UPPER lower MiXeD" + "1. Numbered start" "Dots.in.title")) + (dolist (style '(("_" . "_") ("" . "-"))) + (let* ((pre (car style)) (sep (cdr style)) + (attrs (format ":idprefix: %s\n:idseparator: %s" pre sep)) + (doc (format "= D\n%s\n\n== %s\n" attrs title)) + (html (with-temp-buffer + (insert doc) + (call-process-region (point-min) (point-max) + "asciidoctor" nil t nil "-s" "-o" "-" "-") + (buffer-string))) + (real (when (string-match "id=\"\\([^\"]*\\)\"" html) + (match-string 1 html)))) + (expect (adoc--section-id title pre sep) :to-equal real)))))) + +(describe "adoc--section-id-params" + (it "honours an explicit adoc-section-id-style" + (let ((adoc-section-id-style 'antora)) + (expect (adoc--section-id-params) :to-equal '("" . "-"))) + (let ((adoc-section-id-style 'asciidoctor)) + (expect (adoc--section-id-params) :to-equal '("_" . "_")))) + + (it "reads :idprefix: / :idseparator: from the document" + (with-temp-buffer + (setq buffer-file-name "/tmp/adoc-section-id-fake.adoc") + (insert ":idprefix:\n:idseparator: -\n\n= D\n") + (let ((adoc-section-id-style 'auto)) + (expect (adoc--section-id-params) :to-equal '("" . "-"))))) + + (it "defaults to the Asciidoctor style outside Antora" + (with-temp-buffer + (setq buffer-file-name "/tmp/adoc-section-id-plain.adoc") + (insert "= D\n\n== A\n") + (let ((adoc-section-id-style 'auto)) + (expect (adoc--section-id-params) :to-equal '("_" . "_")))))) + +(describe "Antora layout detection" + (it "detects an antora.yml above the file and uses the kebab style" + (let* ((root (make-temp-file "adoc-antora-" t)) + (pages (expand-file-name "modules/ROOT/pages" root)) + (page (expand-file-name "p.adoc" pages))) + (unwind-protect + (progn + (make-directory pages t) + (with-temp-file (expand-file-name "antora.yml" root) + (insert "name: demo\nversion: ~\n")) + (with-temp-file page (insert "= Page\n\n== My Section\n")) + (with-current-buffer (find-file-noselect page) + (unwind-protect + (let ((adoc-section-id-style 'auto)) + (expect (adoc--antora-p) :to-be-truthy) + (expect (adoc--section-id-params) :to-equal '("" . "-")) + (expect (adoc--collect-section-ids) :to-equal '("my-section"))) + (kill-buffer)))) + (delete-directory root t))))) + +(describe "adoc--collect-sections" + (it "collects section ids, skipping the doctitle and code blocks" + (with-temp-buffer + (insert "= Doc Title\n\n" + "== First Section\n\ntext\n\n" + "----\n== Not A Heading\n----\n\n" + "=== Nested One\n") + (adoc-mode) + (expect (adoc--collect-section-ids) + :to-equal '("_first_section" "_nested_one"))))) + +(describe "section ids as xref targets" + (it "resolves a section auto-id as an xref definition" + (with-temp-buffer + (insert "= Doc\n\n== Clojure CLI Setup\n\ntext\n") + (adoc-mode) + (let ((defs (xref-backend-definitions 'adoc "_clojure_cli_setup"))) + (expect (length defs) :to-equal 1) + (expect (xref-item-summary (car defs)) :to-equal "Clojure CLI Setup")))) + + (it "lets adoc-goto-ref-label jump to a section by its auto-id" + (with-temp-buffer + (insert "= Doc\n\n== First\n\ntext\n\n== Second Section\n\nmore\n") + (adoc-mode) + (goto-char (point-min)) + (adoc-goto-ref-label "_second_section") + (expect (line-number-at-pos) :to-equal 7))) + + (it "offers section ids in the xref completion table" + (with-temp-buffer + (insert "[[explicit]]\n= Doc\n\n== A Section\n") + (adoc-mode) + (let ((table (xref-backend-identifier-completion-table 'adoc))) + (expect (member "a-section" table) :to-be nil) ; default style is underscore + (expect (member "_a_section" table) :to-be-truthy) + (expect (member "explicit" table) :to-be-truthy))))) + +(provide 'adoc-mode-section-id-test) + +;;; adoc-mode-section-id-test.el ends here