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

Reply via email to