;;; 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 ;; ;; 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 ":" ;; 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