Skip to content

Commit

Permalink
Support multiple formatters (radian-software#31)
Browse files Browse the repository at this point in the history
Closes radian-software#31

This commit makes it so apheleia can run multiple formatters one after
the other and use the resultant output to format the current buffer.
This works somewhat like a pipeline. The output of one formatter becomes
the input to the next formatter until all formatters have run and then
an RCS patch is built from the resultant output and applied to the
current buffer.

Note: For convenience we internally represent the users configuration as
a list of formatters even when it may only be one. For example if the
user has configured `(python-mode . black)` in apheleia-mode-alist then
internally we interpret that as a formatter list of `(black)` instead of
`black` as we did previously.
  • Loading branch information
mohkale committed Oct 16, 2021
1 parent 8b9d576 commit 662f1df
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 44 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog].

## Unreleased
### Enhancements
* Support multiple formatters ([#31]).

### Formatters
* [ClangFormat](https://clang.llvm.org/docs/ClangFormat.html) for
C/C++
Expand All @@ -29,6 +32,7 @@ The format is based on [Keep a Changelog].

[#24]: https://github.com/raxod502/apheleia/pull/24
[#30]: https://github.com/raxod502/apheleia/issues/30
[#31]: https://github.com/raxod502/apheleia/issues/31
[#32]: https://github.com/raxod502/apheleia/pull/32
[#39]: https://github.com/raxod502/apheleia/issues/39
[#48]: https://github.com/raxod502/apheleia/pull/48
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,30 @@ variables:
(setf (alist-get 'black apheleia-formatters)
'("black" "--option" "..." "-"))
```
* `apheleia-mode-alist`: Alist mapping major modes and filename
regexps to names of formatters to use in those modes and files. See
the docstring for more information.
* You can use this variable to configure multiple formatters for
the same buffer by setting the `cdr` of an entry to a list of
formatters to run instead of a single formatter. For example you
may want to run `isort` and `black` one after the other.
```elisp
(push '(isort . ("isort" "--stdout" "-"))
apheleia-formatters)
(setf (alist-get 'python-mode apheleia-mode-alist)
'(isort black))
```
This will make apheleia run `isort` on the current buffer and then
`black` on the result of isort and then use the final output to
format the current buffer.
**Warn**: At the moment there's no smart or configurable error
handling in place. This means if one of the configured
formatters fail (for example if `isort` isn't installed) then
apheleia just doesn't format the buffer at all, even if `black`
is installed.
* `apheleia-formatter`: Optional buffer-local variable specifying the
formatter to use in this buffer. Overrides `apheleia-mode-alist`.
Expand Down
143 changes: 100 additions & 43 deletions apheleia.el
Original file line number Diff line number Diff line change
Expand Up @@ -389,20 +389,30 @@ as its sole argument."
;; changes, and 2 if error.
(memq status '(0 1)))))))

(defun apheleia--run-formatter (command callback)
"Run a code formatter on the current buffer.
The formatter is specified by COMMAND, a list of strings or
symbols (see `apheleia-format-buffer'). Invoke CALLBACK with one
argument, a buffer containing the output of the formatter.
If COMMAND uses the symbol `file' and the current buffer is
modified from what is written to disk, then don't do anything."
(defun apheleia--format-command (command &optional stdin)
"Format COMMAND into a shell-ccmd and list of file paths.
Should return a list with the car being the optional input file-name
the cadr being the optional output file-name and the cddr being the
cmd to run.
STDIN is the optional buffer to use when creating a temporary file for
the formatters standard input.
If COMMAND uses the symbol `file' and the current buffer is modified
from what is written to disk, then return nil meaning meaning no
cmd is to be run."
;; WARN: The condition with regards to `file' only checks the original
;; file so it doesn't apply the formats from multiple formatters being
;; run. For example if formatter 1 produces some output and formatter
;; 2 modifies file in place then the changes from formatter 1 may not
;; apply at all.
(cl-block nil
(let ((input-fname nil)
(output-fname nil)
(npx nil))
;; TODO: Support arbitrary package managers, not just npx.
;; TODO: Should we do this only when npx is at the car of cmd?
(when (memq 'npx command)
(setq npx t)
(setq command (remq 'npx command)))
(unless (stringp (car command))
(error "Command cannot start with %S" (car command)))
Expand Down Expand Up @@ -434,7 +444,8 @@ modified from what is written to disk, then don't do anything."
(and buffer-file-name
(file-name-extension
buffer-file-name 'period)))))
(apheleia--write-region-silently nil nil input-fname)
(with-current-buffer (or stdin (current-buffer))
(apheleia--write-region-silently nil nil input-fname))
(setq command (mapcar (lambda (arg)
(if (eq arg 'input)
input-fname
Expand All @@ -447,15 +458,49 @@ modified from what is written to disk, then don't do anything."
output-fname
arg))
command)))
`(,input-fname ,output-fname ,@command))))

(defun apheleia--run-formatters (commands callback &optional stdin)
"Run one or more code formatters on the current buffer.
The formatter is specified by the COMMANDS list. Each entry in
COMMANDS should be a list of strings or symbols (see
`apheleia-format-buffer'). Once all the formatters in COMMANDS
finish succesfully then invoke CALLBACK with one argument, a
buffer containing the output of all the formatters.
STDIN is a buffer containing the standard input for the first
formatter in COMMANDS. This should not be supplied by the caller
and instead is supplied by this command when invoked recursively.
The stdout of the previous formatter becomes the stdin of the
next formatter."
;; WARN: Is there a chance that current-buffer will change when invoked
;; recursively? Say when first called current-buffer is the input file
;; buffer, when called again is it still the original file buffer?
;;
;; To summarise: Does emacs persists current-buffer with lexical scoping?
(when-let ((ret (apheleia--format-command (car commands) stdin)))
(cl-destructuring-bind (input-fname output-fname &rest command) ret
(apheleia--make-process
:command command
:stdin (unless input-fname
(current-buffer))
:callback (lambda (stdout)
(when output-fname
(erase-buffer)
(insert-file-contents-literally output-fname))
(funcall callback stdout))))))
(or stdin (current-buffer)))
:callback
(lambda (stdout)
(when input-fname
(delete-file input-fname))
(when output-fname
;; Load output-fname contents into the stdout buffer.
(with-current-buffer stdout
(erase-buffer)
(insert-file-contents-literally output-fname))
(delete-file output-fname))

(if (cdr commands)
;; Forward current stdout to remaining formatters, passing along
;; the current callback and using the current formatters output
;; as stdin.
(apheleia--run-formatters (cdr commands) callback stdout)
(funcall callback stdout)))))))

(defcustom apheleia-formatters
'((black . ("black" "-"))
Expand Down Expand Up @@ -534,9 +579,17 @@ to match \".jsx\" files you might use \"\\.jsx\\'\"."
If non-nil, then `apheleia-formatters' should have a matching
entry. This overrides `apheleia-mode-alist'.")

(defun apheleia--get-formatter-command (&optional interactive)
(defmacro apheleia--ensure-list (arg)
"Ensure ARG is a list of length at least 1.
When ARG is not a list its turned into a list."
`(when-let ((val ,arg))
(if (listp val)
val
(list val))))

(defun apheleia--get-formatter-commands (&optional interactive)
"Return the formatter command to use for the current buffer.
This is a value suitable for `apheleia--run-formatter', or nil if
This is a value suitable for `apheleia--run-formatters', or nil if
no formatter is configured for the current buffer. Consult the
values of `apheleia-mode-alist' and `apheleia-formatter' to
determine which formatter is configured.
Expand All @@ -545,28 +598,32 @@ If INTERACTIVE is non-nil, then prompt the user for which
formatter to run if none is configured, instead of returning nil.
If INTERACTIVE is the special symbol `prompt', then prompt
even if a formatter is configured."
(when-let ((formatter
(when-let ((formatters
(or (and (not (eq interactive 'prompt))
(or apheleia-formatter
(or (apheleia--ensure-list apheleia-formatter)
(cl-dolist (entry apheleia-mode-alist)
(when (or (and (symbolp (car entry))
(derived-mode-p (car entry)))
(and (stringp (car entry))
buffer-file-name
(string-match-p
(car entry) buffer-file-name)))
(cl-return (cdr entry))))))
(cl-return
(apheleia--ensure-list (cdr entry)))))))
(and interactive
(intern
(completing-read
"Formatter: "
(or (map-keys apheleia-formatters)
(user-error
"No formatters in `apheleia-formatters'"))
nil 'require-match))))))
(or (alist-get formatter apheleia-formatters)
(user-error "No configuration for formatter `%S'"
formatter))))
(list
(intern
(completing-read
"Formatter: "
(or (map-keys apheleia-formatters)
(user-error
"No formatters in `apheleia-formatters'"))
nil 'require-match)))))))
(mapcar (lambda (formatter)
(or (alist-get formatter apheleia-formatters)
(user-error "No configuration for formatter `%S'"
formatter)))
formatters)))

(defun apheleia--buffer-hash ()
"Compute hash of current buffer."
Expand All @@ -583,31 +640,31 @@ even if a formatter is configured."
"Apheleia does not support remote files"))

;;;###autoload
(defun apheleia-format-buffer (command &optional callback)
(defun apheleia-format-buffer (commands &optional callback)
"Run code formatter asynchronously on current buffer, preserving point.
Interactively, run the currently configured formatter (see
`apheleia-formatter' and `apheleia-mode-alist'), or prompt from
`apheleia-formatters' if there is none configured for the current
buffer. With a prefix argument, prompt always.
In Lisp code, COMMAND is similar to what you pass to
In Lisp code, COMMANDS is similar to what you pass to
`make-process', except as follows. Normally, the contents of the
current buffer are passed to the command on stdin, and the output
is read from stdout. However, if you use the symbol `file' as one
of the elements of COMMAND, then the filename of the current
of the elements of COMMANDS, then the filename of the current
buffer is substituted for it. (Use `filepath' instead of `file'
if you need the filename of the current buffer, but you still
want its contents to be passed on stdin.) If you instead use the
symbol `input' as one of the elements of COMMAND, then the
symbol `input' as one of the elements of COMMANDS, then the
contents of the current buffer are written to a temporary file
and its name is substituted for `input'. Also, if you use the
symbol `output' as one of the elements of COMMAND, then it is
symbol `output' as one of the elements of COMMANDS, then it is
substituted with the name of a temporary file. In that case, it
is expected that the command writes to that file, and the file is
then read into an Emacs buffer. Finally, if you use the symbol
`npx' as one of the elements of COMMAND, then the first string
element of COMMAND is resolved inside node_modules/.bin if such a
`npx' as one of the elements of COMMANDS, then the first string
element of COMMANDS is resolved inside node_modules/.bin if such a
directory exists anywhere above the current `default-directory'.
In any case, after the formatter finishes running, the diff
Expand All @@ -624,7 +681,7 @@ changes), CALLBACK, if provided, is invoked with no arguments."
(interactive (progn
(when-let ((err (apheleia--disallowed-p)))
(user-error err))
(list (apheleia--get-formatter-command
(list (apheleia--get-formatter-commands
(if current-prefix-arg
'prompt
'interactive)))))
Expand All @@ -633,8 +690,8 @@ changes), CALLBACK, if provided, is invoked with no arguments."
(unless (apheleia--disallowed-p)
(setq-local apheleia--buffer-hash (apheleia--buffer-hash))
(let ((cur-buffer (current-buffer)))
(apheleia--run-formatter
command
(apheleia--run-formatters
commands
(lambda (formatted-buffer)
(with-current-buffer cur-buffer
;; Short-circuit.
Expand Down Expand Up @@ -669,9 +726,9 @@ operating, to prevent an infinite loop.")
"Run code formatter for current buffer if any configured, then save."
(unless apheleia--format-after-save-in-progress
(when apheleia-mode
(when-let ((command (apheleia--get-formatter-command)))
(when-let ((commands (apheleia--get-formatter-commands)))
(apheleia-format-buffer
command
commands
(lambda ()
(with-demoted-errors "Apheleia: %s"
(let ((apheleia--format-after-save-in-progress t))
Expand Down

0 comments on commit 662f1df

Please sign in to comment.