;;; ess-r-flymake.el --- A ess-r Flymake backend -*- lexical-binding: t; -*-
|
|
;;
|
|
;; Copyright (C) 2018 J. Alexander Branham (alex DOT branham AT gmail DOT com)
|
|
;; Copyright (C) 2018 ESS-core team
|
|
;; Maintainer: ESS-core <ESS-core@r-project.org>
|
|
;;
|
|
;; This file is NOT part of GNU Emacs.
|
|
;;
|
|
;; This is free software; you can redistribute it and/or modify it under
|
|
;; the terms of the GNU General Public License as published by the Free
|
|
;; Software Foundation; either version 3, or (at your option) any later
|
|
;; version.
|
|
;;
|
|
;; This is distributed in the hope that it will be useful, but WITHOUT
|
|
;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
;; FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
;; for more details.
|
|
;;
|
|
;; You should have received a copy of the GNU General Public License
|
|
;; along with GNU Emacs; see the file COPYING. If not, write to the
|
|
;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
|
;; MA 02110-1301 USA.
|
|
;;
|
|
;;; Commentary:
|
|
;;
|
|
;; Flymake is the built-in Emacs package that supports on-the-fly
|
|
;; syntax checking. This file adds support for this in ess-r-mode by
|
|
;; relying on the lintr package, available on CRAN and currently
|
|
;; hosted at https://github.com/jimhester/lintr.
|
|
;;
|
|
;; It is enabled by default.
|
|
;;
|
|
;;; Code:
|
|
|
|
(eval-when-compile (require 'cl-lib))
|
|
(require 'ess-inf)
|
|
(require 'flymake)
|
|
|
|
;; Appease the byte compiler for Emacs 25. Remove after dropping
|
|
;; support for Emacs 25.
|
|
(declare-function flymake-diag-region "flymake")
|
|
(declare-function flymake-make-diagnostic "flymake")
|
|
(declare-function flymake--overlays "flymake")
|
|
|
|
(defcustom ess-r-flymake-linters
|
|
'("closed_curly_linter = NULL"
|
|
"commas_linter = NULL"
|
|
"commented_code_linter = NULL"
|
|
"infix_spaces_linter = NULL"
|
|
"line_length_linter = NULL"
|
|
"object_length_linter = NULL"
|
|
"object_name_linter = NULL"
|
|
"object_usage_linter = NULL"
|
|
"open_curly_linter = NULL"
|
|
"pipe_continuation_linter = NULL"
|
|
"single_quotes_linter = NULL"
|
|
"spaces_inside_linter = NULL"
|
|
"spaces_left_parentheses_linter = NULL"
|
|
"trailing_blank_lines_linter = NULL"
|
|
"trailing_whitespace_linter = NULL")
|
|
"Default linters to use.
|
|
Can be either a string with R expression to be used as
|
|
is (e.g. 'lintr::default_linters'). Or a list of strings where
|
|
each element is passed as argument to 'lintr::with_defaults'."
|
|
:group 'ess-R
|
|
:type '(choice string (repeat string))
|
|
:package-version '(ess . "18.10"))
|
|
|
|
(defcustom ess-r-flymake-lintr-cache t
|
|
"If non-nil, cache lintr results."
|
|
:group 'ess-R
|
|
:type 'boolean
|
|
:package-version '(ess . "18.10"))
|
|
|
|
(defvar-local ess-r--flymake-proc nil)
|
|
|
|
(defvar-local ess-r--lintr-file nil
|
|
"Location of the .lintr file for this buffer.")
|
|
|
|
(defvar ess-r--flymake-def-linter
|
|
(replace-regexp-in-string
|
|
"[\n\t ]+" " "
|
|
"esslint <- function(str, ...) {
|
|
if (!suppressWarnings(require(lintr, quietly=T))) {
|
|
cat('@@error: @@`lintr` package not installed')
|
|
} else {
|
|
if (packageVersion('lintr') <= '1.0.3') {
|
|
cat('@@error: @@Need `lintr` version > v1.0.3')
|
|
} else {
|
|
tryCatch(lintr::lint(commandArgs(TRUE), ...),
|
|
error = function(e) {
|
|
cat('@@warning: @@', e)
|
|
})
|
|
}
|
|
}
|
|
};"))
|
|
|
|
(defun ess-r--find-lintr-file ()
|
|
"Return the absolute path to the .lintr file.
|
|
Check first the current directory, then the project root, then
|
|
the user's home directory. Return nil if we couldn't find a .lintr file."
|
|
(let ((cur-dir-file (expand-file-name ".lintr" default-directory))
|
|
(ess-proj-file (and (fboundp 'ess-r-package-project)
|
|
(ess-r-package-project)
|
|
(expand-file-name ".lintr" (cdr (ess-r-package-project)))))
|
|
(proj-file (and (project-current)
|
|
(project-roots (project-current))
|
|
(expand-file-name ".lintr" (car (project-roots (project-current))))))
|
|
(home-file (expand-file-name ".lintr" (getenv "HOME"))))
|
|
(cond (;; current directory
|
|
(file-readable-p cur-dir-file)
|
|
cur-dir-file)
|
|
;; Project root according to `ess-r-package-project'
|
|
((and ess-proj-file
|
|
(file-readable-p ess-proj-file))
|
|
ess-proj-file)
|
|
;; Project root according to `project-roots'
|
|
((and proj-file
|
|
(file-readable-p proj-file)))
|
|
;; Home directory
|
|
((file-readable-p home-file)
|
|
home-file))))
|
|
|
|
(defun ess-r--flymake-linters ()
|
|
"If `ess-r-flymake-linters' is a string, use that.
|
|
Otherwise, construct a string to pass to lintr::with_defaults."
|
|
(replace-regexp-in-string
|
|
"[\n\t ]+" " "
|
|
(if (stringp ess-r-flymake-linters)
|
|
ess-r-flymake-linters
|
|
(concat "lintr::with_defaults("
|
|
(mapconcat #'identity
|
|
ess-r-flymake-linters
|
|
", ")
|
|
")"))))
|
|
|
|
(defun ess-r--flymake-msg-type (str)
|
|
"Transform STR into log level."
|
|
(cond ((string= str "error: ") :error)
|
|
((string= str "warning: ") :warning)
|
|
((string= str "style: ") :note)
|
|
(t (error "Invalid msg type %s" str))))
|
|
|
|
(defun ess-r--flymake-check-errors ()
|
|
"Check for critical errors and return non-nil if such occurred."
|
|
(goto-char (point-min))
|
|
(when (re-search-forward "@@\\(\\(error\\|warning\\): \\)@@" nil t)
|
|
(let ((type (ess-r--flymake-msg-type (match-string 1)))
|
|
(msg (buffer-substring-no-properties (match-end 0) (point-max))))
|
|
(flymake-log type msg)
|
|
(eq type :error))))
|
|
|
|
(defun ess-r--flymake-parse-output (msg-buffer src-buffer report-fn)
|
|
"Parse the content of MSG-BUFFER for lint locations.
|
|
SRC-BUFFER is the original source buffer. Collect all messages
|
|
into a list and call REPORT-FN on it."
|
|
(with-current-buffer msg-buffer
|
|
(if (ess-r--flymake-check-errors)
|
|
(with-current-buffer src-buffer
|
|
;; we are in the sentinel here; don't throw but remove our hook instead
|
|
(remove-hook 'flymake-diagnostic-functions 'ess-r-flymake t))
|
|
(goto-char (point-min))
|
|
(cl-loop
|
|
while (search-forward-regexp
|
|
;; Regex to match the output lint() gives us.
|
|
(rx line-start "<text>:"
|
|
;; row
|
|
(group-n 1 (one-or-more num)) ":"
|
|
;; column
|
|
(group-n 2 (one-or-more num)) ": "
|
|
;; type
|
|
(group-n 3 (| "style: " "warning: " "error: "))
|
|
;; msg
|
|
(group-n 4 (one-or-more not-newline)) line-end)
|
|
nil t)
|
|
for msg = (match-string 4)
|
|
for (beg . end) = (let ((line (string-to-number (match-string 1)))
|
|
(col (string-to-number (match-string 2))))
|
|
(flymake-diag-region src-buffer line col))
|
|
for type = (ess-r--flymake-msg-type (match-string 3))
|
|
collect (flymake-make-diagnostic src-buffer beg end type msg)
|
|
into diags
|
|
finally (funcall report-fn diags)))))
|
|
|
|
(defun ess-r-flymake (report-fn &rest _args)
|
|
"A Flymake backend for ESS-R modes. Relies on the lintr package.
|
|
REPORT-FN is flymake's callback function."
|
|
(unless (executable-find inferior-ess-r-program)
|
|
(error "Cannot find program '%s'" inferior-ess-r-program))
|
|
;; Kill the process if earlier check was found. The sentinel of the earlier
|
|
;; check will detect this.
|
|
(when (process-live-p ess-r--flymake-proc)
|
|
(kill-process ess-r--flymake-proc))
|
|
(if (and (eql ess-use-flymake 'process)
|
|
(not (ess-process-live-p)))
|
|
(progn
|
|
(funcall report-fn nil)
|
|
(mapc #'delete-overlay (flymake--overlays)))
|
|
(let ((src-buffer (current-buffer)))
|
|
(setq ess-r--flymake-proc
|
|
(make-process
|
|
:name "ess-r-flymake" :noquery t :connection-type 'pipe
|
|
:buffer (generate-new-buffer " *ess-r-flymake*")
|
|
:command (list inferior-R-program
|
|
"--no-save" "--no-restore" "--no-site-file" "--no-init-file" "--slave"
|
|
"-e" (concat
|
|
(when ess-r--lintr-file
|
|
(concat "options(lintr.linter_file = \"" ess-r--lintr-file "\");"))
|
|
ess-r--flymake-def-linter
|
|
;; commandArgs(TRUE) returns everything after
|
|
;; --args as a character vector
|
|
"esslint(commandArgs(TRUE)"
|
|
(unless ess-r--lintr-file
|
|
(concat ", linters = " (ess-r--flymake-linters)))
|
|
(when ess-r-flymake-lintr-cache
|
|
", cache = TRUE")
|
|
")")
|
|
"--args" (buffer-substring-no-properties
|
|
(point-min) (point-max)))
|
|
:sentinel
|
|
(lambda (proc _event)
|
|
(cond
|
|
((eq 'exit (process-status proc))
|
|
(unwind-protect
|
|
(if (eq proc (buffer-local-value 'ess-r--flymake-proc src-buffer))
|
|
(ess-r--flymake-parse-output (process-buffer proc) src-buffer report-fn)
|
|
(flymake-log :warning "Canceling obsolete check %s" proc))
|
|
(kill-buffer (process-buffer proc))))
|
|
((not (eq 'run (process-status proc)))
|
|
(kill-buffer (process-buffer proc))))))))))
|
|
|
|
(defun ess-r-setup-flymake ()
|
|
"Setup flymake for ESS.
|
|
Activate flymake only if `ess-use-flymake' is non-nil."
|
|
(when ess-use-flymake
|
|
(when (< emacs-major-version 26)
|
|
(error "ESS-flymake requires Emacs version 26 or later"))
|
|
(when (string= "R" ess-dialect)
|
|
(setq ess-r--lintr-file (ess-r--find-lintr-file))
|
|
(add-hook 'flymake-diagnostic-functions #'ess-r-flymake nil t)
|
|
;; Try not to enable flymake if flycheck is already running:
|
|
(unless (bound-and-true-p flycheck-mode)
|
|
(flymake-mode)))))
|
|
|
|
;; Enable flymake in Emacs 26+
|
|
(when (<= 26 emacs-major-version)
|
|
(if (eval-when-compile (<= 26 emacs-major-version))
|
|
(add-hook 'ess-r-mode-hook #'ess-r-setup-flymake)
|
|
(when ess-use-flymake
|
|
(display-warning 'ess "ESS was compiled with older version of Emacs;\n `ess-r-flymake' won't be available"))))
|
|
|
|
(provide 'ess-r-flymake)
|
|
|
|
;;; ess-r-flymake.el ends here
|