feat(language/erb): setup linting and formatting of ERB files via erblint

This commit is contained in:
Jim Myhrberg
2024-07-12 02:00:32 +01:00
parent 9e6ce1eaa8
commit d0ef255906
3 changed files with 412 additions and 3 deletions

View File

@@ -8,6 +8,8 @@
(require 'siren-display-fill-column)
(require 'siren-hideshow)
(require 'flycheck-erblint) ;; from vendor directory
(require 'erblintfmt) ;; from vendor directory
(use-package web-mode
:mode
@@ -17,7 +19,8 @@
:general
(:keymaps 'web-mode-map
"C-j" 'newline-and-indent)
"C-j" 'newline-and-indent
"C-c C-f" 'siren-web-mode-format-buffer)
:hook
(web-mode . siren-web-mode-setup)
@@ -38,8 +41,17 @@
(when (version< emacs-version "27.0")
(siren-display-fill-column -1))
(lsp-deferred)
(hs-minor-mode t)))
(hs-minor-mode t))
(defun siren-web-mode-format-buffer ()
"Format the current buffer using relevant tool."
(interactive)
(pcase (file-name-extension (buffer-file-name))
("erb" (erblintfmt))
(_ (message "No formatting tool available for this file type"))))
:config
(flycheck-erblint-setup))
(provide 'siren-web-mode)
;;; siren-web-mode.el ends here

291
vendor/erblintfmt/erblintfmt.el vendored Normal file
View File

@@ -0,0 +1,291 @@
;;; erblintfmt.el --- Minor-mode to format Ruby code with Erblint on save
;; Version: 0.4.1
;; Keywords: convenience wp edit ruby erblint
;; Package-Requires: ((cl-lib "0.5"))
;; URL: https://github.com/jimeh/erblintfmt.el
;; Author: Jim Myhrberg
;; This file is not part of GNU Emacs.
;;; License:
;;
;; Copyright (c) 2014 The go-mode Authors. All rights reserved.
;; Portions Copyright (c) 2018 Jim Myhrberg.
;;
;; Redistribution and use in source and binary forms, with or without
;; modification, are permitted provided that the following conditions are
;; met:
;;
;; * Redistributions of source code must retain the above copyright
;; notice, this list of conditions and the following disclaimer.
;; * Redistributions in binary form must reproduce the above
;; copyright notice, this list of conditions and the following disclaimer
;; in the documentation and/or other materials provided with the
;; distribution.
;; * Neither the name of the copyright holder nor the names of its
;; contributors may be used to endorse or promote products derived from
;; this software without specific prior written permission.
;;
;; THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
;; "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
;; LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
;; A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
;; OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
;; SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
;; LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
;; DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
;; THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
;; (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
;; OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
;;; Commentary:
;;
;; This library formats Ruby code by using erblint and it's --autocorrect
;; option.
;;; Code:
(require 'cl-lib)
(defgroup erblintfmt nil
"Minor mode for formatting Ruby buffers with erblint."
:group 'languages
:link '(url-link "https://github.com/jimeh/erblintfmt.el"))
(defcustom erblintfmt-erblint-command "erblint"
"Name of erblint executable."
:type 'string
:group 'erblintfmt)
(defcustom erblintfmt-use-bundler-when-possible t
"When t and Gemfile is present, run erblint with `bundle exec'."
:type 'boolean
:group 'erblintfmt)
(defcustom erblintfmt-show-errors 'buffer
"Where to display erblintfmt error output.
It can either be displayed in its own buffer, in the echo area,
or not at all.
Please note that Emacs outputs to the echo area when writing
files and will overwrite erblintfmt's echo output if used from
inside a `before-save-hook'."
:type '(choice
(const :tag "Own buffer" buffer)
(const :tag "Echo area" echo)
(const :tag "None" nil))
:group 'erblintfmt)
(defcustom erblintfmt-major-modes '(web-mode)
"List of major modes to format on save when erblintfmt-mode is enabled."
:type '(repeat symbol)
:group 'erblintfmt)
;; (defcustom erblintfmt-on-save-use-lsp-format-buffer nil
;; "EXPERIMENTAL: When set to t and lsp-mode is enabled, use `lsp-format-buffer'
;; to format buffer before saving, instead of `erblintfmt'."
;; :type 'boolean
;; :group 'erblintfmt)
(defun erblintfmt-is-erb-file ()
"Check if the current buffer is an ERB file."
(and buffer-file-name
(string= (file-name-extension buffer-file-name) "erb")))
(defun erblintfmt--apply-rcs-patch (patch-buffer)
"Apply an RCS-formatted diff from PATCH-BUFFER to the current buffer."
(let ((target-buffer (current-buffer))
;; Relative offset between buffer line numbers and line numbers
;; in patch.
;;
;; Line numbers in the patch are based on the source file, so
;; we have to keep an offset when making changes to the
;; buffer.
;;
;; Appending lines decrements the offset (possibly making it
;; negative), deleting lines increments it. This order
;; simplifies the forward-line invocations.
(line-offset 0)
(column (current-column)))
(save-excursion
(with-current-buffer patch-buffer
(goto-char (point-min))
(while (not (eobp))
(unless (looking-at "^\\([ad]\\)\\([0-9]+\\) \\([0-9]+\\)")
(error "Invalid rcs patch or internal error in erblintfmt--apply-rcs-patch"))
(forward-line)
(let ((action (match-string 1))
(from (string-to-number (match-string 2)))
(len (string-to-number (match-string 3))))
(cond
((equal action "a")
(let ((start (point)))
(forward-line len)
(let ((text (buffer-substring start (point))))
(with-current-buffer target-buffer
(cl-decf line-offset len)
(goto-char (point-min))
(forward-line (- from len line-offset))
(insert text)))))
((equal action "d")
(with-current-buffer target-buffer
(erblintfmt--goto-line (- from line-offset))
(cl-incf line-offset len)
(erblintfmt--delete-whole-line len)))
(t
(error "Invalid rcs patch or internal error in erblintfmt--apply-rcs-patch")))))))
(move-to-column column)))
(defun erblintfmt--delete-whole-line (&optional arg)
"Delete the current line without putting it in the `kill-ring'.
Derived from function `kill-whole-line'. ARG is defined as for that
function."
(setq arg (or arg 1))
(if (and (> arg 0)
(eobp)
(save-excursion (forward-visible-line 0) (eobp)))
(signal 'end-of-buffer nil))
(if (and (< arg 0)
(bobp)
(save-excursion (end-of-visible-line) (bobp)))
(signal 'beginning-of-buffer nil))
(cond ((zerop arg)
(delete-region (progn (forward-visible-line 0) (point))
(progn (end-of-visible-line) (point))))
((< arg 0)
(delete-region (progn (end-of-visible-line) (point))
(progn (forward-visible-line (1+ arg))
(unless (bobp)
(backward-char))
(point))))
(t
(delete-region (progn (forward-visible-line 0) (point))
(progn (forward-visible-line arg) (point))))))
(defun erblintfmt--goto-line (line)
"Move cursor to LINE."
(goto-char (point-min))
(forward-line (1- line)))
(defun erblintfmt--bundled-path-p (directory)
"Check if there is a Gemfile in DIRECTORY, or any parent of DIRECTORY."
(erblintfmt--file-search-upward directory "Gemfile"))
(defun erblintfmt--file-search-upward (directory file)
"Search DIRECTORY for FILE and return its full path if found, or NIL if not.
If FILE is not found in DIRECTORY, the parent of DIRECTORY will be searched."
(let ((parent-dir (file-truename (concat (file-name-directory directory) "../")))
(current-path (if (not (string= (substring directory (- (length directory) 1)) "/"))
(concat directory "/" file)
(concat directory file))))
(if (file-exists-p current-path)
current-path
(when (and (not (string= (file-truename directory) parent-dir))
(< (length parent-dir) (length (file-truename directory))))
(erblintfmt--file-search-upward parent-dir file)))))
(defun erblintfmt--parse-result (resultbuf tmpfile)
"Parse Erblint result in RESULTBUF and write corrections to TMPFILE."
(let ((split 0)
(sep (concat "\n[=]+ " (regexp-quote buffer-file-name) " [=]+\n")))
(with-current-buffer resultbuf
(goto-char (point-min))
(setq split (search-forward-regexp sep nil t))
(if split
(when (> split 22)
(goto-char (point-min))
(when (search-forward "error(s) corrected in ERB files" (+ 1 split) t)
(write-region split (point-max) tmpfile)
t))
(erblintfmt--process-errors resultbuf)
nil))))
(defun erblintfmt--process-errors (resultbuf)
"Display contents of RESULTBUF as errors."
(if (eq erblintfmt-show-errors 'echo)
(with-current-buffer resultbuf
(message (buffer-string))))
(if (eq erblintfmt-show-errors 'buffer)
(let ((errbuf (get-buffer-create "*Erblintfmt Errors*")))
(with-current-buffer errbuf
(erase-buffer)
(goto-char (point-min))
(insert-buffer-substring resultbuf))
(display-buffer errbuf))))
;;;###autoload
(defun erblintfmt ()
"Format the current buffer with erblint."
(interactive)
(let* ((coding-system-for-read 'utf-8)
(coding-system-for-write 'utf-8)
(tmpfile (make-temp-file "erblintfmt" nil ".rb"))
(resultbuf (get-buffer-create "*Erblintfmt Result*"))
(patchbuf (get-buffer-create "*Erblintfmt Patch*"))
(buffer-file (file-truename buffer-file-name))
(src-dir (file-name-directory buffer-file))
(src-file (file-name-nondirectory buffer-file))
(fmt-command erblintfmt-erblint-command)
(fmt-args (list "--stdin" src-file
"--autocorrect"
"--format" "compact")))
(if (and erblintfmt-use-bundler-when-possible
(erblintfmt--bundled-path-p src-dir))
(setq fmt-command "bundle"
fmt-args (append (list "exec" erblintfmt-erblint-command)
fmt-args)))
(unwind-protect
(save-restriction
(widen)
(write-region nil nil tmpfile)
(with-current-buffer resultbuf (erase-buffer))
(with-current-buffer patchbuf (erase-buffer))
(let ((current-directory src-dir))
(message "Calling erblint from directory \"%s\": %s %s"
src-dir fmt-command (mapconcat 'identity fmt-args " "))
(apply #'call-process-region (point-min) (point-max)
fmt-command nil resultbuf nil fmt-args)
(if (erblintfmt--parse-result resultbuf tmpfile)
(call-process-region (point-min) (point-max) "diff"
nil patchbuf nil "-n" "-" tmpfile)))
(if (= (buffer-size patchbuf) 0)
(message "Buffer is already erblintfmted")
(erblintfmt--apply-rcs-patch patchbuf)
(message "Applied erblintfmt")))
(delete-file tmpfile)
(kill-buffer resultbuf)
(kill-buffer patchbuf))))
;;;###autoload
(define-minor-mode erblintfmt-mode
"Enable format-on-save for `ruby-mode' buffers via erblintfmt."
:lighter " fmt"
(if erblintfmt-mode
(add-hook 'before-save-hook 'erblintfmt-before-save t t)
(remove-hook 'before-save-hook 'erblintfmt-before-save t)))
(defun erblintfmt-before-save ()
"Format buffer if it is a ERB file.
Major mode most be listed in `erblintfmt-major-modes', and buffer file
name must have a `.erb' extension.
Formatting is done via `erblint'."
(interactive)
(when (and (member major-mode erblintfmt-major-modes)
(erblintfmt-is-erb-file))
(erblintfmt)))
(provide 'erblintfmt)
;;; erblintfmt.el ends here

View File

@@ -0,0 +1,106 @@
;;; flycheck-erblint.el --- Check files with erblint via flycheck.
;; Copyright (C) 2024 Jim Myhrberg
;; Author: Jim Myhrberg <contact@jimeh.me>
;; Keywords: language, linting, tools
;;; Commentary:
;; This package provides a Flycheck checker for ERB files using erblint.
;;; Code:
(require 'flycheck)
(defgroup flycheck-erblint nil
"Check files with erblint via flycheck."
:group 'tools)
(defcustom flycheck-erblint-extra-args nil
"Extra arguments to pass to erblint."
:type '(repeat string)
:group 'flycheck-erblint)
(defun flycheck-erblint--working-directory (&optional _checker)
"Return the nearest directory holding the buf.yaml configuration."
(when buffer-file-name
(let ((config (file-name-directory (flycheck-erblint--config-file)))
(gemfile (file-name-directory (locate-dominating-file buffer-file-name
"Gemfile"))))
;; Return base directory of the config file if it longer (more specific)
;; than that of the Gemfile..
(if (and config gemfile)
(if (> (length config) (length gemfile))
config
gemfile)
(or config gemfile)))))
(defun flycheck-erblint--config-file ()
"Return the nearest directory holding the buf.yaml configuration."
(when buffer-file-name
(let ((dir (locate-dominating-file buffer-file-name
flycheck-erblint-config-filename)))
(if dir
(expand-file-name flycheck-erblint-config-filename dir)))))
(defun flycheck-erblint--is-erb-file ()
"Check if the current buffer is an ERB file."
(and buffer-file-name
(string= (file-name-extension buffer-file-name) "erb")))
(defun flycheck-erblint--parse-json (output checker buffer)
"Parse erblint errors from JSON OUTPUT.
CHECKER and BUFFER denote the CHECKER that returned OUTPUT and
the BUFFER that was checked respectively.
The JSON structure is expected to have a list of files, each with its own
list of offenses.
See URL `https://github.com/Shopify/erb-lint' for more information
about erb-lint."
(let ((errors (car (flycheck-parse-json output))))
(seq-mapcat (lambda (file)
(let-alist file
(seq-map (lambda (offense)
(let-alist offense
(flycheck-error-new
:line .location.start_line
:column (+ 1 .location.start_column)
:level 'error
:message (replace-regexp-in-string "\n" " "
.message)
:checker checker
:buffer buffer
:filename .path
:end-line .location.last_line
:end-column .location.last_column)))
.offenses)))
(alist-get 'files errors))))
(flycheck-def-config-file-var flycheck-erblint-config-filename erblint
".erb-lint.yml")
(flycheck-def-executable-var erblint "erblint")
(flycheck-define-checker erblint
"An Erb linter using erblint.
See URL `https://github.com/Shopify/erb-lint' for more information
about erb-lint."
:command ("erblint" "--format" "json"
(config-file "--config" flycheck-erblint-config-filename)
"--stdin" source-original)
:standard-input t
:working-directory flycheck-erblint--working-directory
:error-parser flycheck-erblint--parse-json
:modes (web-mode)
:predicate flycheck-erblint--is-erb-file)
;;;###autoload
(defun flycheck-erblint-setup ()
"Setup Flycheck erblint."
(interactive)
(add-to-list 'flycheck-checkers 'erblint))
(provide 'flycheck-erblint)
;;; flycheck-erblint.el ends here