;;; actionlint.el --- Flycheck checker for GitHub Actions. -*- lexical-binding: t -*- ;;; Commentary: ;; GitHub Actions are defined using mostly plain YAML files. ;; Actionlint is a linter catching GitHub Action-specific mistakes, and also ;; checks Shell and Python code embedded in Actions (using shellcheck and ;; pyflakes, respectively). ;;; Code: (require 'custom) (require 'flymake) (defgroup actionlint nil "Actionlint-related options." :group 'languages :prefix "actionlint-") (defcustom actionlint-executable "actionlint" "The alidistlint executable to use. This will be looked up in $PATH." :type '(string) :risky t :group 'actionlint) (defvar actionlint--message-regexp (rx bol ":" ; filename (group-n 2 (+ digit)) ":" ; line (group-n 3 (+ digit)) ": " ; column (? (or (seq "pyflakes reported issue in this script: " (group-n 4 (+ digit)) ":" ; inner line (group-n 5 (+ digit)) " ") ; inner column (seq "shellcheck reported issue in this script: " (group-n 8 "SC" (+ digit)) ":" ; shellcheck code (group-n 6 (or "info" "warning" "error")) ":" ; type (group-n 4 (+ digit)) ":" ; inner line (group-n 5 (+ digit)) ": "))) ; inner column (group-n 1 (+? not-newline)) " " ; message "[" (group-n 7 (+ (not ?\]))) "]" eol) ; backend/error name "Regular expression matching messages reported by actionlint. The following convention for match groups is used: 1. free-form message 2. outer line number 3. outer column number 4. (optional) inner line number 5. (optional) inner column number 6. (optional) error level/type 7. backend/error name (e.g. syntax-check or pyflakes) 8. (optional) backend-specific error code The outer line/column numbers are always present and refer to the location of the key where the error is, normally. If the message was passed through from another linter (e.g. shellcheck), it may have an inner line/column, which will be relative to the contents of the key instead.") (defun actionlint--next-message (source) "Return the next message according to REGEXP for buffer SOURCE, if any." (when-let* ((match (search-forward-regexp actionlint--message-regexp nil t)) (inner-line (if-let ((match (match-string 4))) ;; 1-based; don't subtract 1 since we assume ;; that the script actually starts on the next ;; line. (string-to-number match) 0)) (inner-column (if-let ((match (match-string 5))) ;; 1-based; add 1 (assuming 2-space indents) ;; to pick the right place inside the string. (1+ (string-to-number match)) 0)) (region (flymake-diag-region source (+ (string-to-number (match-string 2)) inner-line) (+ (string-to-number (match-string 3)) inner-column))) (type (pcase (match-string 6) ("info" :note) ("warning" :warning) ("error" :error) ('nil :error))) (message (if-let ((code (match-string 8))) (concat (match-string 1) " (" (match-string 7) " " code ")") (concat (match-string 1) " (" (match-string 7) ")")))) (flymake-make-diagnostic source (car region) (cdr region) type message))) (defvar-local actionlint--flymake-proc nil "The latest invocation of actionlint.") ;; See info node: (flymake)An annotated example backend. (defun actionlint-flymake (report-fn &rest _args) "Run actionlint and report diagnostics from it using REPORT-FN. Any running invocations are killed before running another one." (unless (executable-find actionlint-executable) (funcall report-fn :panic :explanation "Cannot find `actionlint-executable' program") (error "Cannot find actionlint executable")) ;; Kill previous check, if it's still running. (when (process-live-p actionlint--flymake-proc) (kill-process actionlint--flymake-proc)) ;; This needs `lexical-binding'. (let ((source (current-buffer))) (save-restriction (widen) (setq actionlint--flymake-proc (make-process :name "actionlint-flymake" :noquery t :connection-type 'pipe ;; Direct output to a temporary buffer. :buffer (generate-new-buffer " *actionlint-flymake*") :command (list actionlint-executable "-oneline" "-no-color" "-") :sentinel (lambda (proc _event) "Parse diagnostic messages once the process PROC has exited." ;; Check the process has actually exited, not just been suspended. (when (memq (process-status proc) '(exit signal)) (unwind-protect ;; Only proceed if we've got the "latest" process. (if (with-current-buffer source (eq proc actionlint--flymake-proc)) (with-current-buffer (process-buffer proc) (goto-char (point-min)) (cl-do (diags (msg (actionlint--next-message source) (actionlint--next-message source))) ((null msg) (funcall report-fn diags)) (push msg diags))) (flymake-log :warning "Canceling obsolete check %s" proc)) ;; Clean up temporary buffer. (kill-buffer (process-buffer proc))))))) ;; Send the buffer to actionlint on stdin. (process-send-region actionlint--flymake-proc (point-min) (point-max)) (process-send-eof actionlint--flymake-proc)))) (defun actionlint-github-workflow-p () "Does the current buffer contain a GitHub Action?" (let ((name (buffer-file-name))) (and name (string-match-p (rx ".github/workflows/" (+ (not ?\/)) ".yml" eos) name)))) (defun actionlint-setup () "Set up actionlint in this buffer, if it is recognised as a workflow file." (when (actionlint-github-workflow-p) (add-hook 'flymake-diagnostic-functions #'actionlint-flymake nil t))) (add-hook 'yaml-mode-hook #'actionlint-setup) (add-hook 'yaml-ts-mode-hook #'actionlint-setup) (provide 'actionlint) ;;; actionlint.el ends here