From 2e9816513789c233acc79493523a7e1aa5b3eeb6 Mon Sep 17 00:00:00 2001 From: Mohsin Kaleem Date: Mon, 27 Dec 2021 18:00:21 +0000 Subject: [PATCH] [#62] Support functions as formatters (#63) * [#62] Support functions as formatters Closes #62. Lets you use a lisp function as a formatter. This gives apheleia a lot more flexibility in regards to what constitutes a formatter. For example you can now plug an external language server or another tool as a formatter for use with apheleia. Here's a very basic example of using indent-line-function with apheleia after merging this commit. Note: this doesn't take into account any special local variables in the original buffer such as lisp-body-indent. It's really just for demonstration purposes and as a proof of concept. ```lisp (defun apheleia-indent-region+ (orig scratch callback) (with-current-buffer scratch (setq-local indent-line-function (buffer-local-value 'indent-line-function orig)) (indent-region (point-min) (point-max)) (funcall callback scratch))) (push '(indent-region . apheleia-indent-region+) apheleia-formatters) (push '(elisp-mode . indent-region) apheleia-mode-alist) (push '(lisp-interaction-mode . indent-region) apheleia-mode-alist) ``` * Fix misc-bugs + prevent race conditions * Update docstring * Reword a bit * Add to README Co-authored-by: Radon Rosborough --- CHANGELOG.md | 4 ++ README.md | 5 ++ apheleia.el | 152 +++++++++++++++++++++++++++++++++++---------------- 3 files changed, 115 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db6f76b..d07eea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The format is based on [Keep a Changelog]. * Support evaluating items in `apheleia-formatters` to make formatter commands more dynamic ([#50], [#55]). * Allow apheleia to format buffers without an underlying file ([#52]). +* Support functional formatters ([#62]). You can now use a lisp + function as a formatter allowing you to plug more powerful + formatters into apheleia such as language servers. ### Formatters * [ClangFormat](https://clang.llvm.org/docs/ClangFormat.html) for @@ -54,6 +57,7 @@ The format is based on [Keep a Changelog]. [#52]: https://github.com/raxod502/apheleia/issues/52 [#54]: https://github.com/raxod502/apheleia/pull/54 [#55]: https://github.com/raxod502/apheleia/issues/55 +[#62]: https://github.com/raxod502/apheleia/issues/62 [#64]: https://github.com/raxod502/apheleia/issues/64 [#65]: https://github.com/raxod502/apheleia/pull/65 diff --git a/README.md b/README.md index 00fa7f1..be22b9f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ variables: example a buffer called `*foo-bar.c*` that has no associated file will have an implicit file-name of `foo-bar.c` and any temporary files will be suffixed with a `.c` extension. + * You can implement formatters as arbitrary Elisp functions which + operate directly on a buffer, without needing to invoke an + external command. This can be useful to integrate with e.g. + language servers. See the docstring for more information on the + expected interface for Elisp formatters. * `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. diff --git a/apheleia.el b/apheleia.el index ed2c794..6d46208 100644 --- a/apheleia.el +++ b/apheleia.el @@ -520,26 +520,17 @@ sequence unless it's first in the sequence")) or list of strings: %S" arg))) `(,input-fname ,output-fname ,stdin ,@command)))) -(defun apheleia--run-formatters (commands buffer 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'). BUFFER is the `current-buffer' when -this function was first called. 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." +(defun apheleia--run-formatter-command (command buffer callback stdin) + "Run a formatter using a shell command. +COMMAND should be a list of string or symbols for the formatter that +will format the current buffer. See `apheleia--run-formatters' for a +description of COMMAND, BUFFER, CALLBACK and STDIN." ;; NOTE: We switch to the original buffer both to format the command ;; correctly and also to ensure any buffer local variables correctly ;; resolve for the whole formatting process (for example ;; `apheleia--current-process'). (with-current-buffer buffer - (when-let ((ret (apheleia--format-command (car commands) stdin))) + (when-let ((ret (apheleia--format-command command stdin))) (cl-destructuring-bind (input-fname output-fname stdin &rest command) ret (apheleia--make-process :command command @@ -552,12 +543,7 @@ next formatter." (erase-buffer) (insert-file-contents-literally 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) buffer callback stdout) - (funcall callback stdout))) + (funcall callback stdout)) :ensure (lambda () (ignore-errors @@ -566,6 +552,69 @@ next formatter." (when output-fname (delete-file output-fname))))))))) +(defun apheleia--run-formatter-function (func buffer callback stdin) + "Run a formatter using a Lisp function FUNC. +See `apheleia--run-formatters' for a description of BUFFER, CALLBACK +and STDIN." + ;; Will be an ugly name if you use a lambda for FUNC, instead of a symbol. + (let* ((formatter-name (if (symbolp func) (symbol-name func) "lambda")) + (scratch (generate-new-buffer + (format " *apheleia-%s-scratch*" formatter-name)))) + (with-current-buffer scratch + ;; We expect FUNC to modify scratch in place so we can't simply pass + ;; STDIN to it. When STDIN isn't nil, it's the output of a previous + ;; formatter and we want to keep it alive so we can debug any issues + ;; with it. + (insert-buffer-substring (or stdin buffer)) + (funcall func + ;; Original buffer being formatted. + buffer + ;; Buffer the formatter should modify. + scratch + ;; Callback after succesfully formatting. + (lambda () + (unwind-protect + (funcall callback scratch) + (kill-buffer scratch))) + ;; Callback when formatting scratch has failed. + (apply-partially #'kill-buffer scratch))))) + +(defun apheleia--run-formatters (commands buffer 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 or a function +\(see `apheleia-format-buffer'). BUFFER is the `current-buffer' when +this function was first called. 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." + (let ((command (car commands))) + (funcall + (cond + ((consp command) + #'apheleia--run-formatter-command) + ((or (functionp command) + (symbolp command)) + #'apheleia--run-formatter-function) + (t + (error "Formatter must be a shell command or a Lisp \ +function: %s" command))) + command + buffer + (lambda (stdout) + (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) buffer callback stdout) + (funcall callback stdout))) + stdin))) + (defcustom apheleia-formatters '((black . ("black" "-")) (brittany . ("brittany")) @@ -582,38 +631,49 @@ next formatter." (terraform . ("terraform" "fmt" "-"))) "Alist of code formatting commands. The keys may be any symbols you want, and the values are -commands, lists of strings and symbols. +shell commands, lists of strings and symbols, or a function +symbol. -In Lisp code, the format of 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 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 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 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 commands, then the first string +If the value is a function, the function will be called with four +arguments to format the current buffer: the original buffer that +was being formatted (use this to access any relevent local +variables or options that the formatter needs); a clone of the +original buffer (that may have been modified by another formatter +prior to being passed to the function); a callback that should be +called when formatting is finished; and another callback that +should be called when an error was raised during formatting. + +Otherwise in Lisp code, the format of 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 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 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 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 commands, then the first string element of the command list is resolved inside node_modules/.bin if such a directory exists anywhere above the current `default-directory'." :type '(alist :key-type symbol :value-type - (repeat - (choice - (string :tag "Argument") - (const :tag "Look for command in node_modules/.bin" npx) - (const :tag "Name of file being formatted" filepath) - (const :tag "Name of real file used for input" file) - (const :tag "Name of temporary file used for input" input) - (const :tag "Name of temporary file used for output" output))))) + (choice + (repeat + (choice + (string :tag "Argument") + (const :tag "Look for command in node_modules/.bin" npx) + (const :tag "Name of file being formatted" filepath) + (const :tag "Name of real file used for input" file) + (const :tag "Name of temporary file used for input" input) + (const :tag "Name of temporary file used for output" output))) + (function :tag "Formatter function")))) (defcustom apheleia-mode-alist '((cc-mode . clang-format)