From 51b8a796b666c6ec7f961e5cb2e05c6aa60475f6 Mon Sep 17 00:00:00 2001 From: Mohsin Kaleem Date: Tue, 17 Oct 2023 21:20:38 +0100 Subject: [PATCH] Restructure and refactor apheleia package (#215) CLOSES #212 PR to restructure apheleia to make it more modular and improve some of the separation of concerns. Before merging we should make sure that we don't reintroduce the issues from #181. --- .github/workflows/lint.yml | 2 +- CHANGELOG.md | 11 + Makefile | 7 +- apheleia-formatter-context.el | 53 + apheleia-core.el => apheleia-formatters.el | 1039 ++++++++------------ apheleia-log.el | 105 ++ apheleia-rcs.el | 229 +++++ apheleia-utils.el | 14 +- apheleia.el | 452 ++++----- test/formatters/apheleia-ft.el | 13 +- 10 files changed, 1022 insertions(+), 903 deletions(-) create mode 100644 apheleia-formatter-context.el rename apheleia-core.el => apheleia-formatters.el (52%) create mode 100644 apheleia-log.el create mode 100644 apheleia-rcs.el diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dff8cf0..5bda9d5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - emacs_version: [26, 27, 28, "master"] + emacs_version: [27, 28, "master"] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc032e..d6773c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog]. respected as before ([#206]). * Disable formatting of go module files with gofmt. This was never supported ([#214]). +* Remove support for Emacs 26 ([#215]). ### Features * New user option `apheleia-formatters-respect-indent-level`, @@ -26,12 +27,21 @@ The format is based on [Keep a Changelog]. * Prettier is now enabled in `svelte-mode`. * More tree-sitter based major modes have been added to `apheleia-mode-alist` ([#191]). +* Autoload the apheleia-goto-error command ([#215]). + +### Internal Changes +* Refactored the organisation of the apheleia package for ease of + understanding and usability ([#215]). ### Bugs fixed * `ktlint` would emit log messages into its stdout when formatting, and these would get spliced into the source file. This has been fixed by suppressing all logs from `ktlint`. * Disable colorized output with the jq formatter ([#213]). +* Fixed apheleia skipped running a formatter on a remote when it isn't + installed locally ([#215]). +* Fixed clang-format formatter did not respect remote file-name component for + the assumed file-name ([#215]). ### Formatters @@ -78,6 +88,7 @@ The format is based on [Keep a Changelog]. [#209]: https://github.com/radian-software/apheleia/pull/209 [#213]: https://github.com/radian-software/apheleia/pull/213 [#214]: https://github.com/radian-software/apheleia/pull/214 +[#215]: https://github.com/radian-software/apheleia/pull/215 ## 3.2 (released 2023-02-25) ### Features diff --git a/Makefile b/Makefile index 1be76b8..e5d360e 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,12 @@ EMACS ?= emacs TAG ?= latest # The order is important for compilation. -for_compile := apheleia-utils.el apheleia.el apheleia-core.el +for_compile := \ + apheleia-formatters.el \ + apheleia-log.el \ + apheleia-rcs.el \ + apheleia-utils.el \ + apheleia.el for_checkdoc := *.el for_checkindent := *.el diff --git a/apheleia-formatter-context.el b/apheleia-formatter-context.el new file mode 100644 index 0000000..769f2ef --- /dev/null +++ b/apheleia-formatter-context.el @@ -0,0 +1,53 @@ +;;; apheleia-formatter-context.el --- Formatter ctx -*- lexical-binding: t -*- + +;;; Commentary: + +;; This file defines a helper for encapsulating common state for a formatter +;; process. + +;;; Code: + +(require 'eieio) + +(defclass apheleia-formatter--context () + ((name + :documentation "The symbol identifier for this formatter. +Set this to nil if the command being run does not correspond to a formatter." + :accessor apheleia-formatter--name) + (arg1 + :documentation "Process used to invoke formatter." + :accessor apheleia-formatter--arg1) + (argv + :documentation "Extra command line arguments for the formatter." + :accessor apheleia-formatter--argv) + (remote + :documentation "Whether this formatter should run on a remote machine. +When set apheleia will use the formatter buffers file-handler, allowing the +process to be spawned on remote machines." + :accessor apheleia-formatter--remote) + (stdin + :documentation "Input buffer. +Set to nil when the formatter reads from a file-path instead of standard +input." + :accessor apheleia-formatter--stdin) + (input-fname + :documentation "Optional path to a temporary copy of the input buffer. +When set the stdin slot is not set and the formatter will be reading from this +file path. `apheleia' will delete this file on cleanup." + :accessor apheleia-formatter--input-fname + :initform nil) + (output-fname + :documentation "Optional path to an temporary output file. +When set the formatter process is meant to write the formatted input to this +file. `apheleia' will delete this file on cleanup." + :accessor apheleia-formatter--output-fname + :initform nil) + (exit-status + :documentation "The exit-code of the formatter process. +This is unset until after the process is run." + :accessor apheleia-formatter--exit-status)) + :documentation "Maintain the state of a formatter process.") + +(provide 'apheleia-formatter-context) + +;;; apheleia-formatter-context.el ends here diff --git a/apheleia-core.el b/apheleia-formatters.el similarity index 52% rename from apheleia-core.el rename to apheleia-formatters.el index 78740b7..254c848 100644 --- a/apheleia-core.el +++ b/apheleia-formatters.el @@ -1,40 +1,312 @@ -;;; apheleia-core.el --- Apheleia core library -*- lexical-binding: t -*- +;;; apheleia-formatters.el --- Run formatters -*- lexical-binding: t -*- ;;; Commentary: -;; `apheleia' core library. -;; -;; This file contains the core of `apheleia'. This includes `apheleia-mode', -;; utility functions for calling formatters based on `apheleia-formatters' -;; and hooks to reformat the current buffer while minimising the displacement -;; to `point'. +;; This module defines a series of functions for running a formatter process +;; or formatter function and generating a RCS patch from the result. ;;; Code: +(require 'apheleia-formatter-context) +(require 'apheleia-log) +(require 'apheleia-utils) + (require 'cl-lib) (require 'map) (require 'subr-x) -(require 'apheleia) - (eval-when-compile (require 'rx)) -(defcustom apheleia-hide-log-buffers nil - "Non-nil means log buffers will be hidden. -Hidden buffers have names that begin with a space, and do not -appear in `switch-to-buffer' unless you type in a space -manually." - :type 'boolean +(defcustom apheleia-formatters + '((astyle . ("astyle" (apheleia-formatters-locate-file + "--options" ".astylerc"))) + (asmfmt . ("asmfmt")) + (bean-format . ("bean-format")) + (beautysh . ("beautysh" + (apheleia-formatters-indent + "--tab" "--indent-size" 'sh-basic-offset) + "-")) + (black . ("black" + (when (apheleia-formatters-extension-p "pyi") "--pyi") + (apheleia-formatters-fill-column "--line-length") + "-")) + (brittany . ("brittany")) + (buildifier . ("buildifier")) + (caddyfmt . ("caddy" "fmt" "-")) + (clang-format . ("clang-format" + "-assume-filename" + (or (apheleia-formatters-local-buffer-file-name) + (apheleia-formatters-mode-extension) + ".c"))) + (cmake-format . ("cmake-format" "-")) + (crystal-tool-format . ("crystal" "tool" "format" "-")) + (dart-format . ("dart" "format")) + (elm-format . ("elm-format" "--yes" "--stdin")) + (fish-indent . ("fish_indent")) + (fourmolu . ("fourmolu")) + (gawk . ("gawk" "-f" "-" "--pretty-print=-")) + (gofmt . ("gofmt")) + (gofumpt . ("gofumpt")) + (goimports . ("goimports")) + (google-java-format . ("google-java-format" "-")) + (html-tidy "tidy" + "--quiet" "yes" + "--tidy-mark" "no" + "--vertical-space" "yes" + "-indent" + (when (derived-mode-p 'nxml-mode) + "-xml") + (apheleia-formatters-indent + "--indent-with-tabs" + "--indent-spaces" + (cond + ((derived-mode-p 'nxml-mode) + 'nxml-child-indent) + ((derived-mode-p 'web-mode) + 'web-mode-indent-style))) + (apheleia-formatters-fill-column "-wrap")) + (isort . ("isort" "-")) + (jq "jq" "." "-M" + (apheleia-formatters-js-indent "--tab" "--indent")) + (lisp-indent . apheleia-indent-lisp-buffer) + (ktlint . ("ktlint" "--log-level=none" "--stdin" "-F" "-")) + (latexindent . ("latexindent" "--logfile=/dev/null")) + (mix-format . ("mix" "format" "-")) + (nixfmt . ("nixfmt")) + (ocamlformat . ("ocamlformat" "-" "--name" filepath + "--enable-outside-detected-project")) + (ormolu . ("ormolu")) + (perltidy . ("perltidy" "--quiet" "--standard-error-output")) + (phpcs . ("apheleia-phpcs")) + (prettier + . (npx "prettier" "--stdin-filepath" filepath + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-css + . (npx "prettier" "--stdin-filepath" filepath "--parser=css" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-html + . (npx "prettier" "--stdin-filepath" filepath "--parser=html" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-graphql + . (npx "prettier" "--stdin-filepath" filepath "--parser=graphql" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-javascript + . (npx "prettier" "--stdin-filepath" filepath "--parser=babel-flow" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-json + . (npx "prettier" "--stdin-filepath" filepath "--parser=json" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-markdown + . (npx "prettier" "--stdin-filepath" filepath "--parser=markdown" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-ruby + . (npx "prettier" "--stdin-filepath" filepath "--parser=ruby" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-scss + . (npx "prettier" "--stdin-filepath" filepath "--parser=scss" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-svelte + . (npx "prettier" "--stdin-filepath" filepath "--parser=svelte" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-typescript + . (npx "prettier" "--stdin-filepath" filepath "--parser=typescript" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (prettier-yaml + . (npx "prettier" "--stdin-filepath" filepath "--parser=yaml" + (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) + (purs-tidy . (npx "purs-tidy" "format")) + (rubocop . ("rubocop" "--stdin" filepath "--auto-correct" + "--stderr" "--format" "quiet" "--fail-level" "fatal")) + (ruby-standard . ("standardrb" "--stdin" filepath "--fix" "--stderr" + "--format" "quiet" "--fail-level" "fatal")) + (shfmt . ("shfmt" + "-filename" filepath + "-ln" (cl-case (bound-and-true-p sh-shell) + (sh "posix") + (t "bash")) + (when apheleia-formatters-respect-indent-level + (list + "-i" (number-to-string + (cond + (indent-tabs-mode 0) + ((boundp 'sh-basic-offset) + sh-basic-offset) + (t 4))))) + "-")) + (rufo . ("rufo" "--filename" filepath "--simple-exit")) + (stylua . ("stylua" "-")) + (rustfmt . ("rustfmt" "--quiet" "--emit" "stdout")) + (terraform . ("terraform" "fmt" "-")) + (yapf . ("yapf"))) + "Alist of code formatting commands. +The keys may be any symbols you want, and the values are shell +commands, lists of strings and symbols, or a function symbol. + +If the value is a function, the function will be called with +keyword arguments (see the implementation of +`apheleia--run-formatter-function' to see which). It should use +`cl-defun' with `&allow-other-keys' for forward compatibility. + +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. + +If you use the symbol `inplace' as one of the elements of the +list, then the contents of the current buffer are written to a +temporary file and its name is substituted for `inplace'. +However, unlike `input', it is expected that the formatter write +the formatted file back to the same file in place. In other +words, `inplace' is like `input' and `output' together. + +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'. + +Any list elements that are not strings and not any of the special +symbols mentioned above will be evaluated when the formatter is +invoked, and spliced into the list. A form can evaluate either to +a string or to a list of strings. + +The \"scripts/formatters\" subdirectory of the Apheleia source +repository is automatically prepended to $PATH (variable +`exec-path', to be specific) when invoking external formatters. +This is intended for internal use. If you would like to define +your own script, you can simply place it on your normal $PATH +rather than using this system." + :type '(alist + :key-type symbol + :value-type + (choice + (repeat + (choice + (string :tag "Argument") + (const :tag "Look for command in node_modules/.bin" npx) + (const :tag "TODO: docstring" inplace) + (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"))) :group 'apheleia) -(defcustom apheleia-log-only-errors t - "Non-nil means Apheleia will only log when an error occurs. -Otherwise, Apheleia will log every time a formatter is run, even -if it is successful." - :type 'boolean +(defcustom apheleia-mode-alist + '(;; Alphabetical please + (asm-mode . asmfmt) + (awk-mode . gawk) + (bash-ts-mode . shfmt) + (bazel-mode . buildifier) + (beancount-mode . bean-format) + (c++-ts-mode . clang-format) + (caddyfile-mode . caddyfmt) + (cc-mode . clang-format) + (c-mode . clang-format) + (c-ts-mode . clang-format) + (c++-mode . clang-format) + (caml-mode . ocamlformat) + (cmake-mode . cmake-format) + (cmake-ts-mode . cmake-format) + (common-lisp-mode . lisp-indent) + (crystal-mode . crystal-tool-format) + (css-mode . prettier-css) + (css-ts-mode . prettier-css) + (dart-mode . dart-format) + (dart-ts-mode . dart-format) + (elixir-mode . mix-format) + (elixir-ts-mode . mix-format) + (elm-mode . elm-format) + (fish-mode . fish-indent) + (go-mode . gofmt) + (go-ts-mode . gofmt) + (graphql-mode . prettier-graphql) + (haskell-mode . brittany) + (html-mode . prettier-html) + (html-ts-mode . prettier-html) + (java-mode . google-java-format) + (java-ts-mode . google-java-format) + (js3-mode . prettier-javascript) + (js-json-mode . prettier-json) + (js-mode . prettier-javascript) + (js-ts-mode . prettier-javascript) + (json-mode . prettier-json) + (json-ts-mode . prettier-json) + (kotlin-mode . ktlint) + (latex-mode . latexindent) + (LaTeX-mode . latexindent) + (lua-mode . stylua) + (lisp-mode . lisp-indent) + (nasm-mode . asmfmt) + (nix-mode . nixfmt) + (perl-mode . perltidy) + (php-mode . phpcs) + (purescript-mode . purs-tidy) + (python-mode . black) + (python-ts-mode . black) + (ruby-mode . prettier-ruby) + (ruby-ts-mode . prettier-ruby) + (rustic-mode . rustfmt) + (rust-mode . rustfmt) + (rust-ts-mode . rustfmt) + (scss-mode . prettier-scss) + (svelte-mode . prettier-svelte) + (terraform-mode . terraform) + (TeX-latex-mode . latexindent) + (TeX-mode . latexindent) + (tsx-ts-mode . prettier-typescript) + (tuareg-mode . ocamlformat) + (typescript-mode . prettier-typescript) + (typescript-ts-mode . prettier-typescript) + (web-mode . prettier) + (yaml-mode . prettier-yaml) + (yaml-ts-mode . prettier-yaml)) + "Alist mapping major mode names to formatters to use in those modes. +This determines what formatter to use in buffers without a +setting for `apheleia-formatter'. The keys are major mode +symbols (matched against `major-mode' with `derived-mode-p') or +strings (matched against value of variable `buffer-file-name' +with `string-match-p'), and the values are symbols with entries +in `apheleia-formatters' (or equivalently, they are allowed +values for `apheleia-formatter'). Values can be a list of such +symnols causing each formatter in the list to be called one after +the other (with the output of the previous formatter). +Earlier entries in this variable take precedence over later ones. + +Be careful when writing regexps to include \"\\'\" and to escape +\"\\.\" in order to properly match a file extension. For example, +to match \".jsx\" files you might use \"\\.jsx\\'\". + +If a given mode derives from another mode (e.g. `php-mode' and +`cc-mode'), then whichever entry in the alist is more specific +will apply. In the case that multiple modes match +`derived-mode-p' for the current buffer but neither derives from +the other, whichever entry comes first will be used." + :type '(alist + :key-type + (choice (symbol :tag "Major mode") + (string :tag "Buffer name regexp")) + :value-type + (choice (symbol :tag "Formatter") + (repeat + (symbol :tag "Formatter")))) :group 'apheleia) + (defcustom apheleia-formatter-exited-hook nil "Abnormal hook run after a formatter has finished running. Must accept arbitrary keyword arguments. The following arguments @@ -74,235 +346,11 @@ compatible with this option and formatters relying on them will crash." (const :tag "Disable formatting for remote buffers" cancel)) :group 'apheleia) -(defcustom apheleia-mode-lighter " Apheleia" - "Lighter for `apheleia-mode'." - :type '(choice :tag "Lighter" (const :tag "No lighter" nil) string) - :risky t - :group 'apheleia) - -(cl-defun apheleia--edit-distance-table (s1 s2) - "Align strings S1 and S2 for minimum edit distance. -Return the dynamic programming table as has table which maps cons -of integers (I1 . I2) to the edit distance between the first I1 -characters of S1 and the first I2 characters of S2." - (let ((table (make-hash-table :test #'equal))) - (dotimes (i1 (1+ (length s1))) - (puthash (cons i1 0) i1 table)) - (dotimes (i2 (1+ (length s2))) - (puthash (cons 0 i2) i2 table)) - (dotimes (i1 (length s1)) - ;; Iterate from 1 to length+1. - (cl-incf i1) - (dotimes (i2 (length s2)) - (cl-incf i2) - (let ((ins (1+ (gethash (cons i1 (1- i2)) table))) - (del (1+ (gethash (cons (1- i1) i2) table))) - (sub (gethash (cons (1- i1) (1- i2)) table))) - (unless (= (aref s1 (1- i1)) (aref s2 (1- i2))) - (cl-incf sub)) - (puthash (cons i1 i2) (min ins del sub) table)))) - table)) - -(defun apheleia--align-point (s1 s2 p1) - "Given strings S1 and S2 and index P1 in S1, return matching index P2 in S2. -If S1 and S2 are the same, then P1 and P2 will also be the same. -Otherwise, the text of S2 surrounding P2 is \"similar\" to the -text of S1 surrounding P1." - (let* ((table (apheleia--edit-distance-table s1 s2)) - (i1 (length s1)) - (i2 (length s2))) - (while (> i1 p1) - (let ((ins (1+ (gethash (cons i1 (1- i2)) table))) - (del (1+ (gethash (cons (1- i1) i2) table))) - (sub (gethash (cons (1- i1) (1- i2)) table))) - (unless (= (aref s1 (1- i1)) (aref s2 (1- i2))) - (cl-incf sub)) - (let ((cost (min ins del sub))) - (cond - ((= cost ins) - (cl-decf i2)) - ((= cost del) - (cl-decf i1)) - ((= cost sub) - (cl-decf i1) - (cl-decf i2)))))) - i2)) - -(defun apheleia--map-rcs-patch (func) - "Map over the RCS patch in the current buffer. -For each RCS patch command, FUNC is called with an alist that has -the following keys: - -- `command': either `addition' or `deletion' -- `start': line number, an integer -- `lines': number of lines to be inserted or removed -- `text': the string to be inserted, only for `addition' - -See -for documentation on the RCS patch format." - (save-excursion - (goto-char (point-min)) - (while (not (= (point) (point-max))) - (unless (looking-at "$\\|\\([ad]\\)\\([0-9]+\\) \\([0-9]+\\)") - (error "Malformed RCS patch: %S" (point))) - (forward-line) - (when-let ((command (match-string 1))) - (let ((start (string-to-number (match-string 2))) - (lines (string-to-number (match-string 3)))) - (pcase command - ("a" - (let ((text-start (point))) - (forward-line lines) - (funcall - func - `((command . addition) - (start . ,start) - (lines . ,lines) - (text . ,(buffer-substring-no-properties - text-start (point))))))) - ("d" - (funcall - func - `((command . deletion) - (start . ,start) - (lines . ,lines)))))))))) - -(defcustom apheleia-max-alignment-size 400 - "Maximum size for diff regions that will have point aligned. -Apheleia uses a dynamic programming algorithm to determine where -point should be placed within a diff region, but this algorithm -has quadratic runtime so it will lock up Emacs if it is run on a -diff region that is too large. The value of this variable serves -as a limit on the input size to the algorithm; larger diff -regions will still be applied, but Apheleia won't try to move -point correctly." - :type 'integer - :group 'apheleia) - -(defun apheleia--apply-rcs-patch (content-buffer patch-buffer) - "Apply RCS patch. -CONTENT-BUFFER contains the text to be patched, and PATCH-BUFFER -contains the patch." - (let ((commands nil) - (point-list nil) - (window-line-list nil)) - (with-current-buffer content-buffer - (push (cons nil (point)) point-list) - (dolist (w (get-buffer-window-list nil nil t)) - (push (cons w (window-point w)) point-list) - (push (cons w (count-lines (window-start w) (point))) - window-line-list))) - (with-current-buffer patch-buffer - (apheleia--map-rcs-patch - (lambda (command) - (with-current-buffer content-buffer - ;; Could be optimized significantly by moving only as many - ;; lines as needed, rather than returning to the beginning - ;; of the buffer first. - (save-excursion - (goto-char (point-min)) - (forward-line (1- (alist-get 'start command))) - ;; Account for the off-by-one error in the RCS patch spec - ;; (namely, text is added *after* the line mentioned in - ;; the patch). - (when (eq (alist-get 'command command) 'addition) - (forward-line)) - (push `(marker . ,(point-marker)) command) - (push command commands) - ;; If we delete a region just before inserting new text - ;; at the same place, then it is a replacement. In this - ;; case, check if the replaced region includes the window - ;; point for any window currently displaying the content - ;; buffer. If so, figure out where that window point - ;; should be moved to, and record the information in an - ;; additional command. - ;; - ;; See . - ;; - ;; Note that the commands get pushed in reverse order - ;; because of how linked lists work. - (let ((deletion (nth 1 commands)) - (addition (nth 0 commands))) - (when (and (eq (alist-get 'command deletion) 'deletion) - (eq (alist-get 'command addition) 'addition) - ;; Again with the weird off-by-one - ;; computations. For example, if you replace - ;; lines 68 through 71 inclusive, then the - ;; deletion is for line 68 and the addition - ;; is for line 70. Blame RCS. - (= (+ (alist-get 'start deletion) - (alist-get 'lines deletion) - -1) - (alist-get 'start addition))) - (let ((text-start (alist-get 'marker deletion))) - (forward-line (alist-get 'lines deletion)) - (let ((text-end (point))) - (dolist (entry point-list) - ;; Check if the (window) point is within the - ;; replaced region. - (cl-destructuring-bind (w . p) entry - (when (and (< text-start p) - (< p text-end)) - (let* ((old-text (buffer-substring-no-properties - text-start text-end)) - (new-text (alist-get 'text addition)) - (old-relative-point (- p text-start)) - (new-relative-point - (if (> (max (length old-text) - (length new-text)) - apheleia-max-alignment-size) - old-relative-point - (apheleia--align-point - old-text new-text old-relative-point)))) - (goto-char text-start) - (push `((marker . ,(point-marker)) - (command . set-point) - (window . ,w) - (relative-point . ,new-relative-point)) - commands)))))))))))))) - (with-current-buffer content-buffer - (let ((move-to nil)) - (save-excursion - (dolist (command (nreverse commands)) - (goto-char (alist-get 'marker command)) - (pcase (alist-get 'command command) - (`addition - (insert (alist-get 'text command))) - (`deletion - (let ((text-start (point))) - (forward-line (alist-get 'lines command)) - (delete-region text-start (point)))) - (`set-point - (let ((new-point - (+ (point) (alist-get 'relative-point command)))) - (if-let ((w (alist-get 'window command))) - (set-window-point w new-point) - (setq move-to new-point))))))) - (when move-to - (goto-char move-to)))) - ;; Restore the scroll position of each window displaying the - ;; buffer. - (dolist (entry window-line-list) - (cl-destructuring-bind (w . old-window-line) entry - (let ((new-window-line - (count-lines (window-start w) (point)))) - (with-selected-window w - ;; Sometimes if the text is less than a buffer long, and - ;; we do a deletion, it might not be possible to keep the - ;; vertical position of point the same by scrolling. - ;; That's okay. We just go as far as we can. - (ignore-errors - (scroll-down (- old-window-line new-window-line))))))))) - (defvar-local apheleia--current-process nil "Current process that Apheleia is running, or nil. Keeping track of this helps avoid running more than one process at once.") -(defvar apheleia--last-error-marker nil - "Marker for the last error message for any formatter. -This points into a log buffer.") - (cl-defun apheleia--make-process (&key name stdin stdout stderr command remote noquery connection-type callback) @@ -403,7 +451,8 @@ NO-QUERY, and CONNECTION-TYPE." (mapconcat #'shell-quote-argument command " ") " < " (shell-quote-argument - (apheleia--strip-remote remote-stdin))))) + (apheleia-formatters-local-buffer-file-name + remote-stdin))))) (unwind-protect (progn (with-current-buffer stdin @@ -434,22 +483,16 @@ NO-QUERY, and CONNECTION-TYPE." (delete-file stderr-file)))) (cl-defun apheleia--execute-formatter-process - (&key command stdin remote callback ensure exit-status formatter) + (&key ctx callback ensure exit-status) "Wrapper for `make-process' that behaves a bit more nicely. -COMMAND is as in `make-process'. STDIN, if given, is a buffer -whose contents are fed to the process on stdin. CALLBACK is -invoked with one argument, the buffer containing the text from -stdout, when the process terminates (if it succeeds). ENSURE is a -callback that's invoked whether the process exited sucessfully or +CTX is a formatter process context (see `apheleia-formatter--context'). +CALLBACK is invoked with one argument, the buffer containing the text +from stdout, when the process terminates (if it succeeds). ENSURE is a +callback that's invoked whether the process exited successfully or not. EXIT-STATUS is a function which is called with the exit status of the command; it should return non-nil to indicate that the command succeeded. If EXIT-STATUS is omitted, then the -command succeeds provided that its exit status is 0. FORMATTER is -the symbol of the formatter that is being run, for diagnostic -purposes. FORMATTER is nil if the command being run does not -correspond to a formatter. REMOTE if non-nil will use the -formatter buffers file-handler, allowing the process to be -spawned on remote machines." +command succeeds provided that its exit status is 0." (when (process-live-p apheleia--current-process) (message "Interrupting %s" apheleia--current-process) (process-put apheleia--current-process :interrupted t) @@ -457,96 +500,54 @@ spawned on remote machines." (accept-process-output apheleia--current-process 0.1 nil 'just-this-one) (when (process-live-p apheleia--current-process) (kill-process apheleia--current-process))) - (let* ((name (file-name-nondirectory (car command))) + (let* ((name (file-name-nondirectory (apheleia-formatter--arg1 ctx))) (stdout (generate-new-buffer (format " *apheleia-%s-stdout*" name))) (stderr (generate-new-buffer (format " *apheleia-%s-stderr*" name))) - (log-name (format "%s*apheleia-%s-log*" - (if apheleia-hide-log-buffers - " " - "") - name))) + (log-name (apheliea-log--buffer-name name))) (condition-case-unless-debug e (progn (setq apheleia--current-process (funcall - (if remote #'apheleia--call-process #'apheleia--make-process) + (if (apheleia-formatter--remote ctx) + #'apheleia--call-process + #'apheleia--make-process) :name (format "apheleia-%s" name) - :stdin stdin + :stdin (apheleia-formatter--stdin ctx) :stdout stdout :stderr stderr - :command command - :remote remote + :command `(,(apheleia-formatter--arg1 ctx) + ,@(apheleia-formatter--argv ctx)) + :remote (apheleia-formatter--remote ctx) :connection-type 'pipe :noquery t :callback (lambda (proc-exit-status proc-interrupted) + (setf (apheleia-formatter--exit-status ctx) + proc-exit-status) (let ((exit-ok (and (not proc-interrupted) (funcall (or exit-status #'zerop) - proc-exit-status)))) + (apheleia-formatter--exit-status ctx))))) ;; Append standard-error from current formatter ;; to log buffer when ;; `apheleia-log-only-errors' is nil or the ;; formatter failed. Every process output is ;; delimited by a line-feed character. (unless (and exit-ok apheleia-log-only-errors) - (with-current-buffer (get-buffer-create log-name) - (special-mode) - (save-restriction - (widen) - (let ((inhibit-read-only t) - (orig-point (point)) - (keep-at-end (eobp)) - (stderr-string - (with-current-buffer stderr - (string-trim (buffer-string))))) - (goto-char (point-max)) - (skip-chars-backward "\n") - (delete-region (point) (point-max)) - (unless (bobp) - (insert - "\n\n\C-l\n")) - (unless exit-ok - (unless apheleia--last-error-marker - (setq apheleia--last-error-marker - (make-marker)) - (move-marker - apheleia--last-error-marker (point)))) - (insert - (current-time-string) - " :: " - (buffer-local-value 'default-directory stdout) - "\n$ " - (mapconcat #'shell-quote-argument command " ") - "\n\n" - (if (string-empty-p stderr-string) - "(no output on stderr)" - stderr-string) - "\n\n" - "Command " - (if exit-ok "succeeded" "failed") - " with exit code " - (number-to-string proc-exit-status) - ".\n") - ;; Known issue: this does not actually - ;; work; point is left at the end of - ;; the previous command output, instead - ;; of being moved to the end of the - ;; buffer for some reason. - (goto-char - (if keep-at-end - (point-max) - (min - (point-max) - orig-point))) - (goto-char (point-max)))))) - (when formatter + (apheleia-log--formatter-result + ctx + log-name + (apheleia-formatter--exit-status ctx) + (buffer-local-value 'default-directory stdout) + (with-current-buffer stderr + (string-trim (buffer-string))))) + (when (apheleia-formatter--name ctx) (run-hook-with-args 'apheleia-formatter-exited-hook - :formatter formatter + :formatter (apheleia-formatter--name ctx) :error (not exit-ok) :log (get-buffer log-name))) (unwind-protect @@ -557,7 +558,7 @@ spawned on remote machines." (concat "Failed to run %s: exit status %s " "(see %s %s)") - (car command) + (apheleia-formatter--arg1 ctx) proc-exit-status (if (string-prefix-p " " log-name) "hidden buffer" @@ -576,14 +577,6 @@ spawned on remote machines." (kill-buffer stderr)) (message "Failed to run %s: %s" name (error-message-string e)))))) -(defun apheleia-goto-error () - "Go to the most recently reported formatter error message." - (interactive) - (unless apheleia--last-error-marker - (user-error "No error has happened yet")) - (pop-to-buffer (marker-buffer apheleia--last-error-marker)) - (goto-char apheleia--last-error-marker)) - (defun apheleia--write-region-silently (start end filename &optional append visit lockname mustbenew write-region) @@ -626,13 +619,6 @@ as in `write-region'. WRITE-REGION is used instead of the actual (apply run-hooks args))))) (save-buffer))) -(defun apheleia--strip-remote (file-name) - "Return FILE-NAME with any TRAMP prefix removed. -If FILE-NAME is not remote, return it unchanged." - (if-let ((remote (file-remote-p file-name))) - (substring file-name (length remote)) - file-name)) - (defun apheleia--make-temp-file (remote prefix &optional dir-flag suffix) "Create a temporary file optionally on a remote machine. This function calls `make-temp-file' or `make-nearby-temp-file' depending on @@ -680,7 +666,7 @@ See `apheleia--run-formatters' for a description of REMOTE." (with-current-buffer buffer (apheleia--write-region-silently (point-min) (point-max) fname))) - (apheleia--strip-remote fname)))) + (apheleia-formatters-local-buffer-file-name fname)))) ;; Ensure file is on target right machine, or create a copy of it. (when old-fname (setq old-fname @@ -692,22 +678,27 @@ See `apheleia--run-formatters' for a description of REMOTE." (unless (or old-fname new-fname) (setq new-fname (apheleia--make-temp-file-for-rcs-patch new-buffer)))) - (apheleia--execute-formatter-process - :command `("diff" "--rcs" "--strip-trailing-cr" "--" - ,(or old-fname "-") - ,(or new-fname "-")) - :stdin (if new-fname old-buffer new-buffer) - :callback callback - :remote remote - :ensure - (lambda () - (dolist (file clear-files) - (ignore-errors - (delete-file file)))) - :exit-status (lambda (status) - ;; Exit status is 0 if no changes, 1 if some - ;; changes, and 2 if error. - (memq status '(0 1)))))) + (let ((ctx (apheleia-formatter--context))) + (setf (apheleia-formatter--name ctx) nil ; Skip logging on failure + (apheleia-formatter--arg1 ctx) "diff" + (apheleia-formatter--argv ctx) `("--rcs" "--strip-trailing-cr" "--" + ,(or old-fname "-") + ,(or new-fname "-")) + (apheleia-formatter--remote ctx) remote + (apheleia-formatter--stdin ctx) + (if new-fname old-buffer new-buffer)) + + (apheleia--execute-formatter-process + :ctx ctx + :callback callback + :ensure + (lambda () + (dolist (file clear-files) + (ignore-errors + (delete-file file)))) + ;; Exit status is 0 if no changes, 1 if some changes, and 2 if + ;; error. + :exit-status (lambda (status) (memq status '(0 1))))))) (defun apheleia--safe-buffer-name () "Return `buffer-name' without special file-system characters." @@ -718,12 +709,22 @@ See `apheleia--run-formatters' for a description of REMOTE." "" (buffer-name))) -(defun apheleia--format-command (command remote &optional stdin-buffer) - "Format COMMAND into a shell command and list of file paths. -Returns a list with the car being the optional input file-name, the -cadr being the optional output file-name, the caddr is the buffer to -send as stdin to the formatter (when the input-fname is not used), -and the cdddr being the cmd to run. +(defun apheleia--replq (dest in out) + "Replace all references to IN with OUT in DEST. +This function does not modify DEST in place, it returns a copy." + (setq in (apheleia--ensure-list in)) + (mapcar (lambda (arg) + (if (memq arg in) + out + arg)) + dest)) + +(defun apheleia--formatter-context (name command remote &optional stdin-buffer) + "Construct a formatter context for the formatter with NAME and COMMAND. +Returns a `apheleia-formatter--context' object on success and nil if +the formatter is not executable. The returned formatter context may +have some state such as temporary files that the caller is expected +to cleanup. STDIN-BUFFER is the optional buffer to use when creating a temporary file for the formatters standard input. REMOTE asserts whether the @@ -734,14 +735,10 @@ 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." (cl-block nil - (let* ((input-fname nil) - (output-fname nil) - ;; Either we're running remotely and the buffer is - ;; remote, or we're not running remotely and the - ;; buffer isn't remote. + (let* ((context (apheleia-formatter--context)) (run-on-remote - (and (eq apheleia-remote-algorithm 'remote) - remote)) + (when (eq apheleia-remote-algorithm 'remote) + remote)) ;; Whether the machine the process will run on matches ;; the machine the buffer/file is currently on. Either ;; we're running remotely and the buffer is remote or @@ -749,26 +746,26 @@ cmd is to be run." ;; remote. (remote-match (equal run-on-remote remote)) (stdin (or stdin-buffer (current-buffer))) - (npx nil) (command (apply #'list command))) + (setf (apheleia-formatter--name context) name) + (setf (apheleia-formatter--stdin context) stdin) + (setf (apheleia-formatter--remote context) remote) ;; TODO: Support arbitrary package managers, not just NPM. (when (memq 'npx command) - (setq npx t) - (setq command (remq 'npx command))) - (when (and npx remote-match) - (when-let ((project-dir - (locate-dominating-file - default-directory "node_modules"))) - (let ((binary - (expand-file-name - (car command) - (expand-file-name - ".bin" + (setq command (remq 'npx command)) + (when remote-match + (when-let ((project-dir + (locate-dominating-file default-directory + "node_modules"))) + (let ((binary (expand-file-name - "node_modules" - project-dir))))) - (when (file-executable-p binary) - (setcar command binary))))) + (car command) + (expand-file-name + ".bin" + (expand-file-name "node_modules" project-dir))))) + (when (file-executable-p binary) + (setcar command binary)))))) + (when (or (memq 'file command) (memq 'filepath command)) ;; Fail when using file but not as the first formatter in this ;; sequence. (But filepath is okay, since it indicates content @@ -787,40 +784,41 @@ machine from the machine file is available on")) (when (and (buffer-modified-p) buffer-file-name) (cl-return))) ;; We always strip out the remote-path prefix for file/filepath. - (let ((file-name (apheleia--strip-remote + (let ((file-name (apheleia-formatters-local-buffer-file-name (or buffer-file-name (concat default-directory (apheleia--safe-buffer-name)))))) - (setq command (mapcar (lambda (arg) - (if (memq arg '(file filepath)) - file-name - arg)) - command)))) + (setq command (apheleia--replq '(file filepath) file-name command)))) + (when (or (memq 'input command) (memq 'inplace command)) - (setq input-fname (apheleia--make-temp-file - run-on-remote "apheleia" nil - (when-let ((file-name - (or buffer-file-name - (apheleia--safe-buffer-name)))) - (file-name-extension file-name 'period)))) - (with-current-buffer stdin - (apheleia--write-region-silently nil nil input-fname)) - (let ((input-fname (apheleia--strip-remote input-fname))) - (setq command (mapcar (lambda (arg) - (if (memq arg '(input inplace)) - (progn - (setq output-fname input-fname) - input-fname) - arg)) - command)))) + (let ((input-fname (apheleia--make-temp-file + run-on-remote "apheleia" nil + (when-let ((file-name + (or buffer-file-name + (apheleia--safe-buffer-name)))) + (file-name-extension file-name 'period))))) + (with-current-buffer stdin + (apheleia--write-region-silently nil nil input-fname)) + (setf (apheleia-formatter--input-fname context) input-fname + (apheleia-formatter--stdin context) nil) + ;; Inplace is the same as input but the output file is the + ;; input file. + (when (memq 'inplace command) + (setf (apheleia-formatter--output-fname context) input-fname)) + (setq command (apheleia--replq + command '(input inplace) + (apheleia-formatters-local-buffer-file-name + input-fname))))) + (when (memq 'output command) - (setq output-fname (apheleia--make-temp-file run-on-remote "apheleia")) - (let ((output-fname (apheleia--strip-remote output-fname))) - (setq command (mapcar (lambda (arg) - (if (eq arg 'output) - output-fname - arg)) - command)))) + (let ((output-fname (apheleia--make-temp-file + run-on-remote "apheleia"))) + (setf (apheleia-formatter--output-fname context) output-fname) + (setq command (apheleia--replq + command 'output + (apheleia-formatters-local-buffer-file-name + output-fname))))) + ;; Evaluate each element of arg that isn't a string and replace ;; it with the evaluated value. The result of an evaluation should ;; be a string or a list of strings. If the former its replaced as @@ -841,7 +839,9 @@ machine from the machine file is available on")) collect val else do (error "Result of command evaluation must be a string \ or list of strings: %S" arg))) - `(,input-fname ,output-fname ,stdin ,@command)))) + (setf (apheleia-formatter--arg1 context) (car command) + (apheleia-formatter--argv context) (cdr command)) + context))) (defun apheleia--run-formatter-process (command buffer remote callback stdin formatter) @@ -856,7 +856,8 @@ purposes." ;; resolve for the whole formatting process (for example ;; `apheleia--current-process'). (with-current-buffer buffer - (when-let ((ret (apheleia--format-command command remote stdin)) + (when-let ((ctx + (apheleia--formatter-context formatter command remote stdin)) (exec-path (append `(,(expand-file-name "scripts/formatters" @@ -866,29 +867,24 @@ purposes." (let ((load-suffixes '(".el"))) (locate-library "apheleia")))))) exec-path))) - (cl-destructuring-bind (input-fname output-fname stdin &rest command) ret - (when (executable-find (car command)) - (apheleia--execute-formatter-process - :command command - :stdin (unless input-fname - stdin) - :callback - (lambda (stdout) - (when output-fname - ;; Load output-fname contents into the stdout buffer. - (with-current-buffer stdout - (erase-buffer) - (insert-file-contents-literally output-fname))) - (funcall callback stdout)) - :ensure - (lambda () - (ignore-errors - (when input-fname - (delete-file input-fname)) - (when output-fname - (delete-file output-fname)))) - :remote remote - :formatter formatter)))))) + (when (executable-find (apheleia-formatter--arg1 ctx) + (eq apheleia-remote-algorithm 'remote)) + (apheleia--execute-formatter-process + :ctx ctx + :callback + (lambda (stdout) + (when-let ((output-fname (apheleia-formatter--output-fname ctx))) + ;; Load output-fname contents into the stdout buffer. + (with-current-buffer stdout + (erase-buffer) + (insert-file-contents-literally output-fname))) + (funcall callback stdout)) + :ensure + (lambda () + (dolist (fname (list (apheleia-formatter--input-fname ctx) + (apheleia-formatter--output-fname ctx))) + (when fname + (ignore-errors (delete-file fname)))))))))) (defun apheleia--run-formatter-function (func buffer remote callback stdin formatter) @@ -1097,173 +1093,6 @@ even if a formatter is configured." "No formatters in `apheleia-formatters'")) nil 'require-match)))))) -(defun apheleia--buffer-hash () - "Compute hash of current buffer." - (if (fboundp 'buffer-hash) - (buffer-hash) - (md5 (current-buffer)))) +(provide 'apheleia-formatters) -(defvar apheleia--buffer-hash nil - "Return value of `buffer-hash' when formatter started running.") - -(defun apheleia--disallowed-p () - "Return an error message if Apheleia cannot be run, else nil." - (when (and buffer-file-name - (file-remote-p (or buffer-file-name - default-directory)) - (eq apheleia-remote-algorithm 'cancel)) - "Apheleia refused to run formatter due to `apheleia-remote-algorithm'")) - -;;;###autoload -(defun apheleia-format-buffer (formatter &optional callback) - "Run code formatter asynchronously on current buffer, preserving point. - -FORMATTER is a symbol appearing as a key in -`apheleia-formatters', or a list of them to run multiple -formatters in a chain. If called interactively, run the currently -configured formatters (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. - -After the formatters finish running, the diff utility is invoked to -determine what changes it made. That diff is then used to apply the -formatter's changes to the current buffer without moving point or -changing the scroll position in any window displaying the buffer. If -the buffer has been modified since the formatter started running, -however, the operation is aborted. - -If the formatter actually finishes running and the buffer is -successfully updated (even if the formatter has not made any -changes), CALLBACK, if provided, is invoked with no arguments." - (interactive (progn - (when-let ((err (apheleia--disallowed-p))) - (user-error err)) - (list (apheleia--get-formatters - (if current-prefix-arg - 'prompt - 'interactive))))) - (let ((formatters (apheleia--ensure-list formatter))) - ;; Check for this error ahead of time so we don't have to deal - ;; with it anywhere in the internal machinery of Apheleia. - (dolist (formatter formatters) - (unless (alist-get formatter apheleia-formatters) - (user-error - "No such formatter defined in `apheleia-formatters': %S" - formatter))) - ;; Fail silently if disallowed, since we don't want to throw an - ;; error on `post-command-hook'. We already took care of throwing - ;; `user-error' on interactive usage above. - (unless (apheleia--disallowed-p) - (setq-local apheleia--buffer-hash (apheleia--buffer-hash)) - (let ((cur-buffer (current-buffer)) - (remote (file-remote-p (or buffer-file-name - default-directory)))) - (apheleia--run-formatters - formatters - cur-buffer - remote - (lambda (formatted-buffer) - (when (buffer-live-p cur-buffer) - (with-current-buffer cur-buffer - ;; Short-circuit. - (when - (equal - apheleia--buffer-hash (apheleia--buffer-hash)) - (apheleia--create-rcs-patch - cur-buffer formatted-buffer remote - (lambda (patch-buffer) - (when (buffer-live-p cur-buffer) - (with-current-buffer cur-buffer - (when - (equal - apheleia--buffer-hash (apheleia--buffer-hash)) - (apheleia--apply-rcs-patch - (current-buffer) patch-buffer) - (when callback - (funcall callback)))))))))))))))) - -(defcustom apheleia-post-format-hook nil - "Normal hook run after Apheleia formats a buffer successfully." - :type 'hook - :group 'apheleia) - -(defcustom apheleia-inhibit-functions nil - "List of functions that prevent Apheleia from turning on automatically. -If one of these returns non-nil then `apheleia-mode' is not -enabled in a buffer, even if `apheleia-global-mode' is on. You -can still manually enable `apheleia-mode' in such a buffer. - -See also `apheleia-inhibit' for another way to accomplish a -similar task." - :type '(repeat function) - :group 'apheleia) - -;; Handle recursive references. -(defvar apheleia-mode) - -;; Prevent infinite loop. -(defvar apheleia--format-after-save-in-progress nil - "Prevent `apheleia--format-after-save' from being called recursively. -This will be locally bound to t while `apheleia--format-after-save' is -operating, to prevent an infinite loop.") - -;; Autoload because the user may enable `apheleia-mode' without -;; loading Apheleia; thus this function may be invoked as an autoload. -;;;###autoload -(defun apheleia--format-after-save () - "Run code formatter for current buffer if any configured, then save." - (unless apheleia--format-after-save-in-progress - (when (and apheleia-mode (not (buffer-narrowed-p))) - (when-let ((formatters (apheleia--get-formatters))) - (apheleia-format-buffer - formatters - (lambda () - (with-demoted-errors "Apheleia: %s" - (when buffer-file-name - (let ((apheleia--format-after-save-in-progress t)) - (apheleia--save-buffer-silently))) - (run-hooks 'apheleia-post-format-hook)))))))) - -;; Use `progn' to force the entire minor mode definition to be copied -;; into the autoloads file, so that the minor mode can be enabled -;; without pulling in all of Apheleia during init. -;;;###autoload -(progn - - (define-minor-mode apheleia-mode - "Minor mode for reformatting code on save without moving point. -It is customized by means of the variables `apheleia-mode-alist' -and `apheleia-formatters'." - :lighter apheleia-mode-lighter - (if apheleia-mode - (add-hook 'after-save-hook #'apheleia--format-after-save nil 'local) - (remove-hook 'after-save-hook #'apheleia--format-after-save 'local))) - - - (defvar-local apheleia-inhibit nil - "Do not enable `apheleia-mode' automatically if non-nil. -This is designed for use in .dir-locals.el. - -See also `apheleia-inhibit-functions'.") - (put 'apheleia-inhibit 'safe-local-variable #'booleanp) - - (defun apheleia-mode-maybe () - "Enable `apheleia-mode' if allowed by user configuration. -This checks `apheleia-inhibit-functions' and `apheleia-inhibit' -to see if it is allowed." - (unless (or - apheleia-inhibit - (run-hook-with-args-until-success - 'apheleia-inhibit-functions)) - (apheleia-mode))) - - (define-globalized-minor-mode apheleia-global-mode - apheleia-mode apheleia-mode-maybe - :group 'apheleia) - - (put 'apheleia-mode 'safe-local-variable #'booleanp)) - -(provide 'apheleia-core) - -;;; apheleia-core.el ends here +;;; apheleia-formatters.el ends here diff --git a/apheleia-log.el b/apheleia-log.el new file mode 100644 index 0000000..4fe8d3d --- /dev/null +++ b/apheleia-log.el @@ -0,0 +1,105 @@ +;;; apheleia-log.el --- Log utilities -*- lexical-binding: t -*- + +;;; Commentary: + +;; Helpers for `apheleia' logging. + +;;; Code: + +(require 'subr-x) + +(require 'apheleia-formatter-context) + +(defcustom apheleia-hide-log-buffers nil + "Non-nil means log buffers will be hidden. +Hidden buffers have names that begin with a space, and do not +appear in `switch-to-buffer' unless you type in a space +manually." + :type 'boolean + :group 'apheleia) + +(defcustom apheleia-log-only-errors t + "Non-nil means Apheleia will only log when an error occurs. +Otherwise, Apheleia will log every time a formatter is run, even +if it is successful." + :type 'boolean + :group 'apheleia) + +(defvar apheleia--last-error-marker nil + "Marker for the last error message for any formatter. +This points into a log buffer.") + +;;;###autoload +(defun apheleia-goto-error () + "Go to the most recently reported formatter error message." + (interactive) + (unless apheleia--last-error-marker + (user-error "No error has happened yet")) + (pop-to-buffer (marker-buffer apheleia--last-error-marker)) + (goto-char apheleia--last-error-marker)) + +(defun apheliea-log--buffer-name (formatter) + "Get the name of the log buffer for FORMATTER." + (format "%s*apheleia-%s-log*" + (if apheleia-hide-log-buffers + " " + "") + formatter)) + +(defun apheleia-log--formatter-result + (ctx log-buffer exit-ok directory stderr-string) + "Log the result of a formatter process. +CTX The formatter process context (see `apheleia-formatter--context'). +LOG-BUFFER is the name of the log-buffer. +EXIT-OK is true when the formatter exited sucesfully. +DIRECTORY is the directory in which the formatter ran. +STDERR-STRING is the stderr output of the formatter." + (with-current-buffer (get-buffer-create log-buffer) + (special-mode) + (save-restriction + (widen) + (let ((inhibit-read-only t) + (orig-point (point)) + (keep-at-end (eobp))) + (goto-char (point-max)) + (skip-chars-backward "\n") + (delete-region (point) (point-max)) + (unless (bobp) + (insert "\n\n\C-l\n")) + + (unless exit-ok + (unless apheleia--last-error-marker + (setq apheleia--last-error-marker (make-marker))) + (move-marker apheleia--last-error-marker (point))) + + (insert + (current-time-string) + " :: " + directory + "\n$ " + (mapconcat #'shell-quote-argument + `(,(apheleia-formatter--arg1 ctx) + ,@(apheleia-formatter--argv ctx)) + " ") + "\n\n" + (if (string-empty-p stderr-string) + "(no output on stderr)" + stderr-string) + "\n\n" + "Command " + (if exit-ok "succeeded" "failed") + " with exit code " + (number-to-string (apheleia-formatter--exit-status ctx)) + ".\n") + ;; Known issue: this does not actually work; point is left at the end + ;; of the previous command output, instead of being moved to the end of + ;; the buffer for some reason. + (goto-char + (if keep-at-end + (point-max) + (min (point-max) orig-point))) + (goto-char (point-max)))))) + +(provide 'apheleia-log) + +;;; apheleia-log.el ends here diff --git a/apheleia-rcs.el b/apheleia-rcs.el new file mode 100644 index 0000000..4b5b87d --- /dev/null +++ b/apheleia-rcs.el @@ -0,0 +1,229 @@ +;;; apheleia-rcs.el --- Apply RCS patches -*- lexical-binding: t -*- + +;;; Commentary: + +;; A library to apply a RCS patch to an Emacs buffer while minimising the +;; displacement of `point'. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) + +(cl-defun apheleia--edit-distance-table (s1 s2) + "Align strings S1 and S2 for minimum edit distance. +Return the dynamic programming table as has table which maps cons +of integers (I1 . I2) to the edit distance between the first I1 +characters of S1 and the first I2 characters of S2." + (let ((table (make-hash-table :test #'equal))) + (dotimes (i1 (1+ (length s1))) + (puthash (cons i1 0) i1 table)) + (dotimes (i2 (1+ (length s2))) + (puthash (cons 0 i2) i2 table)) + (dotimes (i1 (length s1)) + ;; Iterate from 1 to length+1. + (cl-incf i1) + (dotimes (i2 (length s2)) + (cl-incf i2) + (let ((ins (1+ (gethash (cons i1 (1- i2)) table))) + (del (1+ (gethash (cons (1- i1) i2) table))) + (sub (gethash (cons (1- i1) (1- i2)) table))) + (unless (= (aref s1 (1- i1)) (aref s2 (1- i2))) + (cl-incf sub)) + (puthash (cons i1 i2) (min ins del sub) table)))) + table)) + +(defun apheleia--align-point (s1 s2 p1) + "Given strings S1 and S2 and index P1 in S1, return matching index P2 in S2. +If S1 and S2 are the same, then P1 and P2 will also be the same. +Otherwise, the text of S2 surrounding P2 is \"similar\" to the +text of S1 surrounding P1." + (let* ((table (apheleia--edit-distance-table s1 s2)) + (i1 (length s1)) + (i2 (length s2))) + (while (> i1 p1) + (let ((ins (1+ (gethash (cons i1 (1- i2)) table))) + (del (1+ (gethash (cons (1- i1) i2) table))) + (sub (gethash (cons (1- i1) (1- i2)) table))) + (unless (= (aref s1 (1- i1)) (aref s2 (1- i2))) + (cl-incf sub)) + (let ((cost (min ins del sub))) + (cond + ((= cost ins) + (cl-decf i2)) + ((= cost del) + (cl-decf i1)) + ((= cost sub) + (cl-decf i1) + (cl-decf i2)))))) + i2)) + +(defun apheleia--map-rcs-patch (func) + "Map over the RCS patch in the current buffer. +For each RCS patch command, FUNC is called with an alist that has +the following keys: + +- `command': either `addition' or `deletion' +- `start': line number, an integer +- `lines': number of lines to be inserted or removed +- `text': the string to be inserted, only for `addition' + +See +for documentation on the RCS patch format." + (save-excursion + (goto-char (point-min)) + (while (not (= (point) (point-max))) + (unless (looking-at "$\\|\\([ad]\\)\\([0-9]+\\) \\([0-9]+\\)") + (error "Malformed RCS patch: %S" (point))) + (forward-line) + (when-let ((command (match-string 1))) + (let ((start (string-to-number (match-string 2))) + (lines (string-to-number (match-string 3)))) + (pcase command + ("a" + (let ((text-start (point))) + (forward-line lines) + (funcall + func + `((command . addition) + (start . ,start) + (lines . ,lines) + (text . ,(buffer-substring-no-properties + text-start (point))))))) + ("d" + (funcall + func + `((command . deletion) + (start . ,start) + (lines . ,lines)))))))))) + +(defcustom apheleia-max-alignment-size 400 + "Maximum size for diff regions that will have point aligned. +Apheleia uses a dynamic programming algorithm to determine where +point should be placed within a diff region, but this algorithm +has quadratic runtime so it will lock up Emacs if it is run on a +diff region that is too large. The value of this variable serves +as a limit on the input size to the algorithm; larger diff +regions will still be applied, but Apheleia won't try to move +point correctly." + :type 'integer + :group 'apheleia) + +(defun apheleia--apply-rcs-patch (content-buffer patch-buffer) + "Apply RCS patch. +CONTENT-BUFFER contains the text to be patched, and PATCH-BUFFER +contains the patch." + (let ((commands nil) + (point-list nil) + (window-line-list nil)) + (with-current-buffer content-buffer + (push (cons nil (point)) point-list) + (dolist (w (get-buffer-window-list nil nil t)) + (push (cons w (window-point w)) point-list) + (push (cons w (count-lines (window-start w) (point))) + window-line-list))) + (with-current-buffer patch-buffer + (apheleia--map-rcs-patch + (lambda (command) + (with-current-buffer content-buffer + ;; Could be optimized significantly by moving only as many + ;; lines as needed, rather than returning to the beginning + ;; of the buffer first. + (save-excursion + (goto-char (point-min)) + (forward-line (1- (alist-get 'start command))) + ;; Account for the off-by-one error in the RCS patch spec + ;; (namely, text is added *after* the line mentioned in + ;; the patch). + (when (eq (alist-get 'command command) 'addition) + (forward-line)) + (push `(marker . ,(point-marker)) command) + (push command commands) + ;; If we delete a region just before inserting new text + ;; at the same place, then it is a replacement. In this + ;; case, check if the replaced region includes the window + ;; point for any window currently displaying the content + ;; buffer. If so, figure out where that window point + ;; should be moved to, and record the information in an + ;; additional command. + ;; + ;; See . + ;; + ;; Note that the commands get pushed in reverse order + ;; because of how linked lists work. + (let ((deletion (nth 1 commands)) + (addition (nth 0 commands))) + (when (and (eq (alist-get 'command deletion) 'deletion) + (eq (alist-get 'command addition) 'addition) + ;; Again with the weird off-by-one + ;; computations. For example, if you replace + ;; lines 68 through 71 inclusive, then the + ;; deletion is for line 68 and the addition + ;; is for line 70. Blame RCS. + (= (+ (alist-get 'start deletion) + (alist-get 'lines deletion) + -1) + (alist-get 'start addition))) + (let ((text-start (alist-get 'marker deletion))) + (forward-line (alist-get 'lines deletion)) + (let ((text-end (point))) + (dolist (entry point-list) + ;; Check if the (window) point is within the + ;; replaced region. + (cl-destructuring-bind (w . p) entry + (when (and (< text-start p) + (< p text-end)) + (let* ((old-text (buffer-substring-no-properties + text-start text-end)) + (new-text (alist-get 'text addition)) + (old-relative-point (- p text-start)) + (new-relative-point + (if (> (max (length old-text) + (length new-text)) + apheleia-max-alignment-size) + old-relative-point + (apheleia--align-point + old-text new-text old-relative-point)))) + (goto-char text-start) + (push `((marker . ,(point-marker)) + (command . set-point) + (window . ,w) + (relative-point . ,new-relative-point)) + commands)))))))))))))) + (with-current-buffer content-buffer + (let ((move-to nil)) + (save-excursion + (dolist (command (nreverse commands)) + (goto-char (alist-get 'marker command)) + (pcase (alist-get 'command command) + (`addition + (insert (alist-get 'text command))) + (`deletion + (let ((text-start (point))) + (forward-line (alist-get 'lines command)) + (delete-region text-start (point)))) + (`set-point + (let ((new-point + (+ (point) (alist-get 'relative-point command)))) + (if-let ((w (alist-get 'window command))) + (set-window-point w new-point) + (setq move-to new-point))))))) + (when move-to + (goto-char move-to)))) + ;; Restore the scroll position of each window displaying the + ;; buffer. + (dolist (entry window-line-list) + (cl-destructuring-bind (w . old-window-line) entry + (let ((new-window-line + (count-lines (window-start w) (point)))) + (with-selected-window w + ;; Sometimes if the text is less than a buffer long, and + ;; we do a deletion, it might not be possible to keep the + ;; vertical position of point the same by scrolling. + ;; That's okay. We just go as far as we can. + (ignore-errors + (scroll-down (- old-window-line new-window-line))))))))) + +(provide 'apheleia-rcs) + +;;; apheleia-rcs.el ends here diff --git a/apheleia-utils.el b/apheleia-utils.el index 391d6a5..a749668 100644 --- a/apheleia-utils.el +++ b/apheleia-utils.el @@ -98,13 +98,13 @@ Otherwise return the extension only." (list flag ext) ext))) -(defun apheleia-formatters-local-buffer-file-name () - "Get variable `buffer-file-name' without any remote components." - (when-let ((name buffer-file-name)) - (let ((remote (file-remote-p name))) - (if remote - (substring name (length remote)) - name)))) +(defun apheleia-formatters-local-buffer-file-name (&optional file-name) + "Get FILE-NAME without any remote components. +FILE-NAME defaults to variable `buffer-file-name'." + (when-let ((file-name (or file-name buffer-file-name))) + (if-let ((remote (file-remote-p file-name))) + (substring file-name (length remote)) + file-name))) (provide 'apheleia-utils) diff --git a/apheleia.el b/apheleia.el index 7c23125..e46898b 100644 --- a/apheleia.el +++ b/apheleia.el @@ -6,7 +6,7 @@ ;; Created: 7 Jul 2019 ;; Homepage: https://github.com/radian-software/apheleia ;; Keywords: tools -;; Package-Requires: ((emacs "26")) +;; Package-Requires: ((emacs "27")) ;; SPDX-License-Identifier: MIT ;; Version: 3.2 @@ -23,7 +23,8 @@ ;;; Code: -(require 'apheleia-utils) +(require 'apheleia-formatters) +(require 'apheleia-rcs) (defgroup apheleia nil "Reformat buffer without moving point." @@ -31,293 +32,178 @@ :link '(url-link :tag "GitHub" "https://github.com/radian-software/apheleia") :link '(emacs-commentary-link :tag "Commentary" "apheleia")) -(defcustom apheleia-formatters - '((astyle . ("astyle" (apheleia-formatters-locate-file - "--options" ".astylerc"))) - (asmfmt . ("asmfmt")) - (bean-format . ("bean-format")) - (beautysh . ("beautysh" - (apheleia-formatters-indent - "--tab" "--indent-size" 'sh-basic-offset) - "-")) - (black . ("black" - (when (apheleia-formatters-extension-p "pyi") "--pyi") - (apheleia-formatters-fill-column "--line-length") - "-")) - (brittany . ("brittany")) - (buildifier . ("buildifier")) - (caddyfmt . ("caddy" "fmt" "-")) - (clang-format . ("clang-format" - "-assume-filename" - (or (buffer-file-name) - (apheleia-formatters-mode-extension) - ".c"))) - (cmake-format . ("cmake-format" "-")) - (crystal-tool-format . ("crystal" "tool" "format" "-")) - (dart-format . ("dart" "format")) - (elm-format . ("elm-format" "--yes" "--stdin")) - (fish-indent . ("fish_indent")) - (fourmolu . ("fourmolu")) - (gawk . ("gawk" "-f" "-" "--pretty-print=-")) - (gofmt . ("gofmt")) - (gofumpt . ("gofumpt")) - (goimports . ("goimports")) - (google-java-format . ("google-java-format" "-")) - (html-tidy "tidy" - "--quiet" "yes" - "--tidy-mark" "no" - "--vertical-space" "yes" - "-indent" - (when (derived-mode-p 'nxml-mode) - "-xml") - (apheleia-formatters-indent - "--indent-with-tabs" - "--indent-spaces" - (cond - ((derived-mode-p 'nxml-mode) - 'nxml-child-indent) - ((derived-mode-p 'web-mode) - 'web-mode-indent-style))) - (apheleia-formatters-fill-column "-wrap")) - (isort . ("isort" "-")) - (jq "jq" "." "-M" - (apheleia-formatters-js-indent "--tab" "--indent")) - (lisp-indent . apheleia-indent-lisp-buffer) - (ktlint . ("ktlint" "--log-level=none" "--stdin" "-F" "-")) - (latexindent . ("latexindent" "--logfile=/dev/null")) - (mix-format . ("mix" "format" "-")) - (nixfmt . ("nixfmt")) - (ocamlformat . ("ocamlformat" "-" "--name" filepath - "--enable-outside-detected-project")) - (ormolu . ("ormolu")) - (perltidy . ("perltidy" "--quiet" "--standard-error-output")) - (phpcs . ("apheleia-phpcs")) - (prettier - . (npx "prettier" "--stdin-filepath" filepath - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-css - . (npx "prettier" "--stdin-filepath" filepath "--parser=css" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-html - . (npx "prettier" "--stdin-filepath" filepath "--parser=html" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-graphql - . (npx "prettier" "--stdin-filepath" filepath "--parser=graphql" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-javascript - . (npx "prettier" "--stdin-filepath" filepath "--parser=babel-flow" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-json - . (npx "prettier" "--stdin-filepath" filepath "--parser=json" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-markdown - . (npx "prettier" "--stdin-filepath" filepath "--parser=markdown" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-ruby - . (npx "prettier" "--stdin-filepath" filepath "--parser=ruby" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-scss - . (npx "prettier" "--stdin-filepath" filepath "--parser=scss" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-svelte - . (npx "prettier" "--stdin-filepath" filepath "--parser=svelte" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-typescript - . (npx "prettier" "--stdin-filepath" filepath "--parser=typescript" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (prettier-yaml - . (npx "prettier" "--stdin-filepath" filepath "--parser=yaml" - (apheleia-formatters-js-indent "--use-tabs" "--tab-width"))) - (purs-tidy . (npx "purs-tidy" "format")) - (rubocop . ("rubocop" "--stdin" filepath "--auto-correct" - "--stderr" "--format" "quiet" "--fail-level" "fatal")) - (ruby-standard . ("standardrb" "--stdin" filepath "--fix" "--stderr" - "--format" "quiet" "--fail-level" "fatal")) - (shfmt . ("shfmt" - "-filename" filepath - "-ln" (cl-case (bound-and-true-p sh-shell) - (sh "posix") - (t "bash")) - (when apheleia-formatters-respect-indent-level - (list - "-i" (number-to-string - (cond - (indent-tabs-mode 0) - ((boundp 'sh-basic-offset) - sh-basic-offset) - (t 4))))) - "-")) - (rufo . ("rufo" "--filename" filepath "--simple-exit")) - (stylua . ("stylua" "-")) - (rustfmt . ("rustfmt" "--quiet" "--emit" "stdout")) - (terraform . ("terraform" "fmt" "-")) - (yapf . ("yapf"))) - "Alist of code formatting commands. -The keys may be any symbols you want, and the values are shell -commands, lists of strings and symbols, or a function symbol. - -If the value is a function, the function will be called with -keyword arguments (see the implementation of -`apheleia--run-formatter-function' to see which). It should use -`cl-defun' with `&allow-other-keys' for forward compatibility. - -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. - -If you use the symbol `inplace' as one of the elements of the -list, then the contents of the current buffer are written to a -temporary file and its name is substituted for `inplace'. -However, unlike `input', it is expected that the formatter write -the formatted file back to the same file in place. In other -words, `inplace' is like `input' and `output' together. - -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'. - -Any list elements that are not strings and not any of the special -symbols mentioned above will be evaluated when the formatter is -invoked, and spliced into the list. A form can evaluate either to -a string or to a list of strings. - -The \"scripts/formatters\" subdirectory of the Apheleia source -repository is automatically prepended to $PATH (variable -`exec-path', to be specific) when invoking external formatters. -This is intended for internal use. If you would like to define -your own script, you can simply place it on your normal $PATH -rather than using this system." - :type '(alist - :key-type symbol - :value-type - (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-lighter " Apheleia" + "Lighter for `apheleia-mode'." + :type '(choice :tag "Lighter" (const :tag "No lighter" nil) string) + :risky t :group 'apheleia) -(defcustom apheleia-mode-alist - '(;; Alphabetical please - (asm-mode . asmfmt) - (awk-mode . gawk) - (bash-ts-mode . shfmt) - (bazel-mode . buildifier) - (beancount-mode . bean-format) - (c++-ts-mode . clang-format) - (caddyfile-mode . caddyfmt) - (cc-mode . clang-format) - (c-mode . clang-format) - (c-ts-mode . clang-format) - (c++-mode . clang-format) - (caml-mode . ocamlformat) - (cmake-mode . cmake-format) - (cmake-ts-mode . cmake-format) - (common-lisp-mode . lisp-indent) - (crystal-mode . crystal-tool-format) - (css-mode . prettier-css) - (css-ts-mode . prettier-css) - (dart-mode . dart-format) - (dart-ts-mode . dart-format) - (elixir-mode . mix-format) - (elixir-ts-mode . mix-format) - (elm-mode . elm-format) - (fish-mode . fish-indent) - (go-mode . gofmt) - (go-ts-mode . gofmt) - (graphql-mode . prettier-graphql) - (haskell-mode . brittany) - (html-mode . prettier-html) - (html-ts-mode . prettier-html) - (java-mode . google-java-format) - (java-ts-mode . google-java-format) - (js3-mode . prettier-javascript) - (js-json-mode . prettier-json) - (js-mode . prettier-javascript) - (js-ts-mode . prettier-javascript) - (json-mode . prettier-json) - (json-ts-mode . prettier-json) - (kotlin-mode . ktlint) - (latex-mode . latexindent) - (LaTeX-mode . latexindent) - (lua-mode . stylua) - (lisp-mode . lisp-indent) - (nasm-mode . asmfmt) - (nix-mode . nixfmt) - (perl-mode . perltidy) - (php-mode . phpcs) - (purescript-mode . purs-tidy) - (python-mode . black) - (python-ts-mode . black) - (ruby-mode . prettier-ruby) - (ruby-ts-mode . prettier-ruby) - (rustic-mode . rustfmt) - (rust-mode . rustfmt) - (rust-ts-mode . rustfmt) - (scss-mode . prettier-scss) - (svelte-mode . prettier-svelte) - (terraform-mode . terraform) - (TeX-latex-mode . latexindent) - (TeX-mode . latexindent) - (tsx-ts-mode . prettier-typescript) - (tuareg-mode . ocamlformat) - (typescript-mode . prettier-typescript) - (typescript-ts-mode . prettier-typescript) - (web-mode . prettier) - (yaml-mode . prettier-yaml) - (yaml-ts-mode . prettier-yaml)) - "Alist mapping major mode names to formatters to use in those modes. -This determines what formatter to use in buffers without a -setting for `apheleia-formatter'. The keys are major mode -symbols (matched against `major-mode' with `derived-mode-p') or -strings (matched against value of variable `buffer-file-name' -with `string-match-p'), and the values are symbols with entries -in `apheleia-formatters' (or equivalently, they are allowed -values for `apheleia-formatter'). Values can be a list of such -symnols causing each formatter in the list to be called one after -the other (with the output of the previous formatter). -Earlier entries in this variable take precedence over later ones. +(defun apheleia--buffer-hash () + "Compute hash of current buffer." + (if (fboundp 'buffer-hash) + (buffer-hash) + (md5 (current-buffer)))) -Be careful when writing regexps to include \"\\'\" and to escape -\"\\.\" in order to properly match a file extension. For example, -to match \".jsx\" files you might use \"\\.jsx\\'\". +(defvar apheleia--buffer-hash nil + "Return value of `buffer-hash' when formatter started running.") -If a given mode derives from another mode (e.g. `php-mode' and -`cc-mode'), then whichever entry in the alist is more specific -will apply. In the case that multiple modes match -`derived-mode-p' for the current buffer but neither derives from -the other, whichever entry comes first will be used." - :type '(alist - :key-type - (choice (symbol :tag "Major mode") - (string :tag "Buffer name regexp")) - :value-type - (choice (symbol :tag "Formatter") - (repeat - (symbol :tag "Formatter")))) +(defun apheleia--disallowed-p () + "Return an error message if Apheleia cannot be run, else nil." + (when (and buffer-file-name + (file-remote-p (or buffer-file-name + default-directory)) + (eq apheleia-remote-algorithm 'cancel)) + "Apheleia refused to run formatter due to `apheleia-remote-algorithm'")) + +;;;###autoload +(defun apheleia-format-buffer (formatter &optional callback) + "Run code formatter asynchronously on current buffer, preserving point. + +FORMATTER is a symbol appearing as a key in +`apheleia-formatters', or a list of them to run multiple +formatters in a chain. If called interactively, run the currently +configured formatters (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. + +After the formatters finish running, the diff utility is invoked to +determine what changes it made. That diff is then used to apply the +formatter's changes to the current buffer without moving point or +changing the scroll position in any window displaying the buffer. If +the buffer has been modified since the formatter started running, +however, the operation is aborted. + +If the formatter actually finishes running and the buffer is +successfully updated (even if the formatter has not made any +changes), CALLBACK, if provided, is invoked with no arguments." + (interactive (progn + (when-let ((err (apheleia--disallowed-p))) + (user-error err)) + (list (apheleia--get-formatters + (if current-prefix-arg + 'prompt + 'interactive))))) + (let ((formatters (apheleia--ensure-list formatter))) + ;; Check for this error ahead of time so we don't have to deal + ;; with it anywhere in the internal machinery of Apheleia. + (dolist (formatter formatters) + (unless (alist-get formatter apheleia-formatters) + (user-error + "No such formatter defined in `apheleia-formatters': %S" + formatter))) + ;; Fail silently if disallowed, since we don't want to throw an + ;; error on `post-command-hook'. We already took care of throwing + ;; `user-error' on interactive usage above. + (unless (apheleia--disallowed-p) + (setq-local apheleia--buffer-hash (apheleia--buffer-hash)) + (let ((cur-buffer (current-buffer)) + (remote (file-remote-p (or buffer-file-name + default-directory)))) + (apheleia--run-formatters + formatters + cur-buffer + remote + (lambda (formatted-buffer) + (when (buffer-live-p cur-buffer) + (with-current-buffer cur-buffer + ;; Short-circuit. + (when + (equal + apheleia--buffer-hash (apheleia--buffer-hash)) + (apheleia--create-rcs-patch + cur-buffer formatted-buffer remote + (lambda (patch-buffer) + (when (buffer-live-p cur-buffer) + (with-current-buffer cur-buffer + (when + (equal + apheleia--buffer-hash (apheleia--buffer-hash)) + (apheleia--apply-rcs-patch + (current-buffer) patch-buffer) + (when callback + (funcall callback)))))))))))))))) + +(defcustom apheleia-post-format-hook nil + "Normal hook run after Apheleia formats a buffer successfully." + :type 'hook :group 'apheleia) +(defcustom apheleia-inhibit-functions nil + "List of functions that prevent Apheleia from turning on automatically. +If one of these returns non-nil then `apheleia-mode' is not +enabled in a buffer, even if `apheleia-global-mode' is on. You +can still manually enable `apheleia-mode' in such a buffer. + +See also `apheleia-inhibit' for another way to accomplish a +similar task." + :type '(repeat function) + :group 'apheleia) + +;; Handle recursive references. +(defvar apheleia-mode) + +;; Prevent infinite loop. +(defvar apheleia--format-after-save-in-progress nil + "Prevent `apheleia--format-after-save' from being called recursively. +This will be locally bound to t while `apheleia--format-after-save' is +operating, to prevent an infinite loop.") + +;; Autoload because the user may enable `apheleia-mode' without +;; loading Apheleia; thus this function may be invoked as an autoload. +;;;###autoload +(defun apheleia--format-after-save () + "Run code formatter for current buffer if any configured, then save." + (unless apheleia--format-after-save-in-progress + (when (and apheleia-mode (not (buffer-narrowed-p))) + (when-let ((formatters (apheleia--get-formatters))) + (apheleia-format-buffer + formatters + (lambda () + (with-demoted-errors "Apheleia: %s" + (when buffer-file-name + (let ((apheleia--format-after-save-in-progress t)) + (apheleia--save-buffer-silently))) + (run-hooks 'apheleia-post-format-hook)))))))) + +;; Use `progn' to force the entire minor mode definition to be copied +;; into the autoloads file, so that the minor mode can be enabled +;; without pulling in all of Apheleia during init. +;;;###autoload +(progn + + (define-minor-mode apheleia-mode + "Minor mode for reformatting code on save without moving point. +It is customized by means of the variables `apheleia-mode-alist' +and `apheleia-formatters'." + :lighter apheleia-mode-lighter + (if apheleia-mode + (add-hook 'after-save-hook #'apheleia--format-after-save nil 'local) + (remove-hook 'after-save-hook #'apheleia--format-after-save 'local))) + + (defvar-local apheleia-inhibit nil + "Do not enable `apheleia-mode' automatically if non-nil. +This is designed for use in .dir-locals.el. + +See also `apheleia-inhibit-functions'.") + (put 'apheleia-inhibit 'safe-local-variable #'booleanp) + + (defun apheleia-mode-maybe () + "Enable `apheleia-mode' if allowed by user configuration. +This checks `apheleia-inhibit-functions' and `apheleia-inhibit' +to see if it is allowed." + (unless (or + apheleia-inhibit + (run-hook-with-args-until-success + 'apheleia-inhibit-functions)) + (apheleia-mode))) + + (define-globalized-minor-mode apheleia-global-mode + apheleia-mode apheleia-mode-maybe + :group 'apheleia) + + (put 'apheleia-mode 'safe-local-variable #'booleanp)) + (provide 'apheleia) ;;; apheleia.el ends here diff --git a/test/formatters/apheleia-ft.el b/test/formatters/apheleia-ft.el index 38877f7..5396115 100755 --- a/test/formatters/apheleia-ft.el +++ b/test/formatters/apheleia-ft.el @@ -5,7 +5,6 @@ ;; breaking changes may occur at any time. (require 'apheleia) -(require 'apheleia-core) (require 'cl-lib) (require 'map) @@ -65,7 +64,7 @@ already in memory on the current branch." invocation-name) nil (current-buffer) nil "--batch" "-L" old-apheleia - "--eval" "(require 'apheleia-core)" + "--eval" "(require 'apheleia)" "--eval" "(prin1 apheleia-formatters)") (goto-char (point-min)) (read (current-buffer))))) @@ -307,10 +306,12 @@ environment variable, defaulting to all formatters." (copy-to-buffer stdout-buffer (point-min) (point-max)))) (progn - (let ((result (apheleia--format-command command nil nil))) - (setq command (nthcdr 3 result) - in-temp-real-file (nth 0 result) - out-temp-file (nth 1 result))) + (let ((ctx (apheleia--formatter-context + (intern formatter) command nil nil))) + (setq command `(,(apheleia-formatter--arg1 ctx) + ,@(apheleia-formatter--argv ctx)) + in-temp-real-file (apheleia-formatter--input-fname ctx) + out-temp-file (apheleia-formatter--output-fname ctx))) (with-current-buffer stdout-buffer (erase-buffer))