branch: elpa/adoc-mode
commit 64cc307c687397fc553a29c69ddc6adf0389f111
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
Add a Flymake backend backed by Asciidoctor
Round out the diagnostics story: a flymake-diagnostic-functions backend
runs the buffer through asciidoctor (over stdin, so unsaved edits count)
and surfaces its parser errors and warnings inline. It's registered in
adoc-mode automatically, so turning on flymake-mode is all it takes; a
fatal asciidoctor failure with no locatable line is reported at the top of
the buffer rather than swallowed.
The parsing is factored into adoc--flymake-parse-output and unit-tested
separately from the async process plumbing, which has its own end-to-end
and kill-during-check tests (guarded on asciidoctor being installed).
---
CHANGELOG.md | 1 +
README.adoc | 19 ++++++---
adoc-asciidoctor.el | 86 ++++++++++++++++++++++++++++++++++++++
adoc-mode.el | 3 ++
test/adoc-mode-asciidoctor-test.el | 84 +++++++++++++++++++++++++++++++++++++
5 files changed, 188 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a86f8e44b7..b5fb267ffd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,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.
### Changes
diff --git a/README.adoc b/README.adoc
index db874a2ee7..5c3de447e9 100644
--- a/README.adoc
+++ b/README.adoc
@@ -53,6 +53,7 @@ Here are some of the main features of `adoc-mode`:
- 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
+- on-the-fly diagnostics through a built-in Flymake backend (and
out-of-the-box support for Flycheck's `asciidoctor` checker)
- integration with `flyspell-mode` (skips non-prose regions) and comment
commands (`M-;`)
- filling that respects AsciiDoc hard line breaks (a line ending in ` +`) and
section titles
@@ -169,16 +170,24 @@ Pass extra arguments to every Asciidoctor invocation via
=== Syntax Checking
-If you use https://www.flycheck.org/[Flycheck], it ships a built-in
-`asciidoctor` checker that works in `adoc-mode` out of the box - it runs the
-`asciidoctor` command and reports its errors and warnings inline. Just enable
-`flycheck-mode` (and make sure `asciidoctor` is on your `PATH`):
+`adoc-mode` ships a
https://www.gnu.org/software/emacs/manual/html_node/flymake/[Flymake]
+backend that runs the buffer through Asciidoctor and reports its parser errors
+and warnings inline. It's registered automatically, so you just turn on
+`flymake-mode` (and make sure `asciidoctor` is on your `PATH`):
[source,emacs-lisp]
----
-(add-hook 'adoc-mode-hook #'flycheck-mode)
+(add-hook 'adoc-mode-hook #'flymake-mode)
----
+The check feeds the buffer to Asciidoctor over its standard input, so it runs
+on unsaved edits, and it jumps with the usual `flymake-goto-next-error` /
+`flymake-goto-prev-error`.
+
+Prefer https://www.flycheck.org/[Flycheck]? It ships its own built-in
+`asciidoctor` checker that also works in `adoc-mode` out of the box - just
+enable `flycheck-mode` instead.
+
=== Image Preview
If you click kbd:[mouse-3] on an image link like
diff --git a/adoc-asciidoctor.el b/adoc-asciidoctor.el
index b8704ff126..96ee35c758 100644
--- a/adoc-asciidoctor.el
+++ b/adoc-asciidoctor.el
@@ -35,6 +35,7 @@
;;; Code:
(require 'compile)
+(require 'flymake)
(require 'subr-x)
(require 'transient)
@@ -244,6 +245,91 @@ preview in place rather than showing a blank page."
(remove-hook 'kill-buffer-hook #'adoc--preview-cleanup t)
(adoc--preview-cleanup)))
+;;; Flymake
+
+(defconst adoc--flymake-diagnostic-re
+ "^asciidoctor: \\(ERROR\\|WARNING\\|DEPRECATED\\): <stdin>: [Ll]ine
\\([0-9]+\\): \\(.*\\)$"
+ "Regexp matching an Asciidoctor diagnostic about the standard input document.
+Group 1 is the severity, group 2 the line number, group 3 the message.
+Asciidoctor has used both `line' and `Line' across versions, hence the
+case-insensitive alternative.")
+
+(defvar-local adoc--flymake-proc nil
+ "The most recent Asciidoctor Flymake process for this buffer.")
+
+(defun adoc--flymake-parse-output (output source &optional exit-status)
+ "Parse Asciidoctor OUTPUT into Flymake diagnostics for buffer SOURCE.
+OUTPUT is the combined standard error/output of an Asciidoctor run over
+the buffer's contents. When EXIT-STATUS is non-zero and no per-line
+diagnostics are found, the first Asciidoctor message is reported as a
+buffer-level error so a fatal failure is not swallowed."
+ (let ((diags '())
+ (count 0))
+ (with-temp-buffer
+ (insert output)
+ (goto-char (point-min))
+ (while (re-search-forward adoc--flymake-diagnostic-re nil t)
+ (let* ((type (pcase (match-string 1)
+ ("ERROR" :error)
+ ("WARNING" :warning)
+ (_ :note)))
+ (line (string-to-number (match-string 2)))
+ (msg (match-string 3))
+ (region (flymake-diag-region source line)))
+ (when region
+ (setq count (1+ count))
+ (push (flymake-make-diagnostic source (car region) (cdr region)
+ type msg)
+ diags))))
+ ;; Surface a fatal failure (e.g. a missing converter library) that
+ ;; produced no locatable diagnostics, anchored to the top of the buffer.
+ (when (and (zerop count) exit-status (not (zerop exit-status)))
+ (goto-char (point-min))
+ (when (re-search-forward "^asciidoctor: .+$" nil t)
+ (when-let* ((region (flymake-diag-region source 1)))
+ (push (flymake-make-diagnostic source (car region) (cdr region)
+ :error (match-string 0))
+ diags)))))
+ (nreverse diags)))
+
+(defun adoc-flymake (report-fn &rest _args)
+ "An AsciiDoc Flymake backend using Asciidoctor.
+Runs the current buffer through `asciidoctor' and converts its parser
+diagnostics into Flymake reports via REPORT-FN. Suitable as a member of
+`flymake-diagnostic-functions'."
+ (unless (executable-find adoc-asciidoctor-command)
+ (error "Cannot find the Asciidoctor executable %S"
adoc-asciidoctor-command))
+ (when (process-live-p adoc--flymake-proc)
+ (kill-process adoc--flymake-proc))
+ (let ((source (current-buffer))
+ (base (expand-file-name default-directory)))
+ (save-restriction
+ (widen)
+ (setq
+ adoc--flymake-proc
+ (make-process
+ :name "adoc-flymake" :noquery t :connection-type 'pipe
+ :buffer (generate-new-buffer " *adoc-flymake*")
+ :command (append (list adoc-asciidoctor-command)
+ adoc-asciidoctor-extra-args
+ (list "-B" base "-o" null-device "-"))
+ :sentinel
+ (lambda (proc _event)
+ (when (memq (process-status proc) '(exit signal))
+ (unwind-protect
+ (if (and (buffer-live-p source)
+ (with-current-buffer source
+ (eq proc adoc--flymake-proc)))
+ (funcall report-fn
+ (adoc--flymake-parse-output
+ (with-current-buffer (process-buffer proc)
+ (buffer-string))
+ source (process-exit-status proc)))
+ (flymake-log :warning "Canceling obsolete check %s" proc))
+ (kill-buffer (process-buffer proc)))))))
+ (process-send-region adoc--flymake-proc (point-min) (point-max))
+ (process-send-eof adoc--flymake-proc))))
+
;;; Transient menu
;;;###autoload (autoload 'adoc-asciidoctor-menu "adoc-asciidoctor" nil t)
diff --git a/adoc-mode.el b/adoc-mode.el
index b494e13b32..e98855061c 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -4499,6 +4499,9 @@ Turning on Adoc mode runs the normal hook
`adoc-mode-hook'."
;; completion
(add-hook 'completion-at-point-functions #'adoc-completion-at-point nil t)
+ ;; diagnostics (opt-in: contributes when the user enables `flymake-mode')
+ (add-hook 'flymake-diagnostic-functions #'adoc-flymake nil t)
+
;; misc
(setq-local page-delimiter "^<<<+$")
(setq-local require-final-newline mode-require-final-newline)
diff --git a/test/adoc-mode-asciidoctor-test.el
b/test/adoc-mode-asciidoctor-test.el
index 01dcece6f7..1cf48f7e55 100644
--- a/test/adoc-mode-asciidoctor-test.el
+++ b/test/adoc-mode-asciidoctor-test.el
@@ -123,6 +123,90 @@
(expect (match-string 2 line) :to-equal "doc.txt")
(expect (match-string 3 line) :to-equal "9")))))
+(describe "adoc--flymake-parse-output"
+ ;; A source buffer with enough lines for the diagnostics to map onto.
+ (defun adoc-test--flymake-source ()
+ (let ((buf (generate-new-buffer " *adoc-flymake-src*")))
+ (with-current-buffer buf
+ (insert "l1\nl2\nl3\nl4\nl5\nl6\nl7\n"))
+ buf))
+
+ (it "maps ERROR and WARNING lines to diagnostics"
+ (let* ((src (adoc-test--flymake-source))
+ (out (concat
+ "asciidoctor: ERROR: <stdin>: line 5: include file not found:
x\n"
+ "asciidoctor: WARNING: <stdin>: line 7: unterminated table
block\n"))
+ (diags (adoc--flymake-parse-output out src 0)))
+ (unwind-protect
+ (progn
+ (expect (length diags) :to-equal 2)
+ (expect (flymake-diagnostic-type (nth 0 diags)) :to-be :error)
+ (expect (flymake-diagnostic-text (nth 0 diags))
+ :to-equal "include file not found: x")
+ (expect (flymake-diagnostic-type (nth 1 diags)) :to-be :warning))
+ (kill-buffer src))))
+
+ (it "matches the capitalised `Line' form some Asciidoctor versions emit"
+ (let* ((src (adoc-test--flymake-source))
+ (diags (adoc--flymake-parse-output
+ "asciidoctor: WARNING: <stdin>: Line 3: something\n" src
0)))
+ (unwind-protect
+ (expect (length diags) :to-equal 1)
+ (kill-buffer src))))
+
+ (it "reports a fatal failure with no locatable line as a top-of-buffer error"
+ (let* ((src (adoc-test--flymake-source))
+ (diags (adoc--flymake-parse-output
+ "asciidoctor: FAILED: 'asciidoctor-pdf' could not be
loaded\n"
+ src 1)))
+ (unwind-protect
+ (progn
+ (expect (length diags) :to-equal 1)
+ (expect (flymake-diagnostic-type (car diags)) :to-be :error))
+ (kill-buffer src))))
+
+ (it "returns no diagnostics for clean output"
+ (let ((src (adoc-test--flymake-source)))
+ (unwind-protect
+ (expect (adoc--flymake-parse-output "" src 0) :to-be nil)
+ (kill-buffer src)))))
+
+(describe "adoc-flymake (integration)"
+ (it "reports real Asciidoctor diagnostics end to end"
+ (assume (executable-find "asciidoctor") "asciidoctor not installed")
+ (with-temp-buffer
+ (insert "= Title\n\n== A\n\ninclude::/no/such.adoc[]\n")
+ (adoc-mode)
+ (let ((result :pending))
+ (adoc-flymake (lambda (diags &rest _) (setq result diags)))
+ (let ((tries 0))
+ (while (and (eq result :pending) (< tries 100))
+ (accept-process-output adoc--flymake-proc 0.1)
+ (setq tries (1+ tries))))
+ (expect result :not :to-be :pending)
+ (expect (cl-some (lambda (d) (eq (flymake-diagnostic-type d) :error))
+ result)
+ :to-be-truthy))))
+
+ (it "does not error when the source buffer is killed mid-check"
+ (assume (executable-find "asciidoctor") "asciidoctor not installed")
+ (let ((buf (generate-new-buffer "adoc-flymake-kill.adoc"))
+ (called nil))
+ (with-current-buffer buf
+ (insert "= Title\n\ninclude::/no/such.adoc[]\n")
+ (adoc-mode)
+ (adoc-flymake (lambda (&rest _) (setq called t))))
+ (let ((proc (buffer-local-value 'adoc--flymake-proc buf)))
+ ;; kill the source buffer while asciidoctor is still running
+ (kill-buffer buf)
+ ;; draining the sentinel must not signal, and report-fn must not run
+ (let ((tries 0))
+ (while (and (process-live-p proc) (< tries 100))
+ (accept-process-output proc 0.1)
+ (setq tries (1+ tries))))
+ (expect (process-live-p proc) :to-be nil)
+ (expect called :to-be nil)))))
+
(provide 'adoc-mode-asciidoctor-test)
;;; adoc-mode-asciidoctor-test.el ends here