Automatically Fixing Python flake8 and pylint Errors in Emacs

Having recently installed elpy in my emacs environment and enabled flake8, I was presented with many warnings an errors based on style. At first I just starting disabling them all to avoid the noise and hassle of fixing them. Then I got the idea that it shouldn't be that hard to create a fixer system to automatically fix at least the most simple stylistic errors. This idea was inspired in part by the automatic fix-up stuff I found while checking out Pycharm.

To create this system I started out with elisp only, and then in an attempt to learn how to use pymacs incorprated that. In this post I'm going to focus on the elisp version.

By checking out the code to display the error on the current line from flymake I derived a pair of functions and an associative array to invoke functions given a certain error found. Then I wrote fixer functions and added them to the array. I also I bind "C-cC-\" to pyfixer:fix-current-line.

Here's the code with a single fixer function defined. Eventually I plan on hosting this on github so that people may contribute fixers.

(defun pyfixer:add-blank-line (errno errinfo)
  "Add blank line above current line"
  (save-excursion
    (beginning-of-line)
    (newline)))

(setq pyfixer:flymake-fixers
      '(
	("E301" . pyfixer:add-blank-line)
	("E302" . pyfixer:add-blank-line)
	))

(defun pyfixer:fix-error (errdata)
  "Fix the given errdata"
  (if errdata
      (let (errno fixer)
	;; Handle flake8 or pylint messages
	(if (string-match "\\(^\\([EW][0-9]+\\) \\|\\[\\([EW][0-9]+\\)\\(-.*\\)\\?*\\]\\).*" errdata)
	    (progn
	      (if (not (setq errno (match-string 2 errdata)))
		  (setq errno (match-string 3 errdata)))
	      (setq fixer (cdr (assoc errno pyfixer:flymake-fixers)))
	      ;; If we don't have and elisp fixer check pymacs fixers
	      (if (and (not fixer) (functionp 'pyfixers-get-fixer-func))
		  (setq fixer (pyfixers-get-fixer-func errno)))
	      (if fixer
		  (progn
		    (funcall fixer errno errdata)
		    (flymake-start-syntax-check))
		(message "No pyfixer function for: %s" errno)))))))

(defun pyfixer:fix-current-line ()
  "Display a fix for the current line"
  (interactive)
  (let* ((line-no             (flymake-current-line-no))
	 (line-err-info-list  (nth 0 (flymake-find-err-info flymake-err-info line-no)))
	 (menu-data           (flymake-make-err-menu-data line-no line-err-info-list))
	 (errlist (caadr menu-data)))
    (message "Errlist: %s" errlist)
    (mapcar 'pyfixer:fix-error errlist)))

(global-set-key "\C-c\C-\\" 'pyfixer:fix-current-line)

Now this fixer function is quite simple and doesn't deal with some cases. For example if a comment immediately precedes the function definition it is not counted as a blank line, but the extra line will be between it and the function definition. To fix this we backup to the first non-comment line before adding any newlines. Additionally, let's parse the error message to determine exactly how many newlines we need to add. Here's the revised fixer function:

(defun line-no-commentp ()
  (save-match-data
    (let* ((start (line-beginning-position))
	   (end (line-end-position))
	   (line (buffer-substring-no-properties start end)))
      (not (string-match "[:space:]*#.*" line)))))

(defun pyfixer:add-blank-line (errno errinfo)
  "Add blank line above current line"
  (save-excursion
    (let ((lines 0))
      (if (string-match "expected \\([0-9]+\\) blank lines?, found \\([0-9]+\\)" errinfo)
	  (setq lines (- (string-to-number (match-string 1 errinfo))
			 (string-to-number (match-string 2 errinfo)))))
      (beginning-of-line)
      (previous-line)
      (while (not (line-no-commentp))
	(previous-line))
      (next-line)
      (newline lines))))