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))