-
Notifications
You must be signed in to change notification settings - Fork 74
/
apheleia.el
818 lines (765 loc) · 34.8 KB
/
apheleia.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
;;; apheleia.el --- Reformat buffer stably -*- lexical-binding: t -*-
;; Copyright (C) 2019 Radon Rosborough
;; Author: Radon Rosborough <radon.neon@gmail.com>
;; Created: 7 Jul 2019
;; Homepage: https://github.com/raxod502/apheleia
;; Keywords: tools
;; Package-Requires: ((emacs "25.2"))
;; SPDX-License-Identifier: MIT
;; Version: 1.1.2
;;; Commentary:
;; Apheleia is an Emacs Lisp package which allows you to reformat a
;; buffer without moving point. This solves the usual problem of
;; running a tool like Prettier or Black on `before-save-hook', namely
;; that it resets point to the beginning of the buffer. Apheleia
;; maintains the position of point relative to its surrounding text
;; even if the buffer is modified by the reformatting.
;; Please see https://github.com/raxod502/apheleia for more information.
;;; Code:
(require 'cl-lib)
(require 'map)
(require 'subr-x)
(eval-when-compile
(require 'rx))
(defgroup apheleia nil
"Reformat buffer without moving point."
:group 'external
:link '(url-link :tag "GitHub" "https://github.com/raxod502/apheleia")
:link '(emacs-commentary-link :tag "Commentary" "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 <https://tools.ietf.org/doc/tcllib/html/rcs.html#section4>
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)
(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 <https://www.gnu.org/software/emacs/manual/html_node/elisp/Window-Point.html>.
;;
;; 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 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.")
(cl-defun apheleia--make-process
(&key command stdin 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 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."
(when (process-live-p apheleia--current-process)
(interrupt-process apheleia--current-process)
(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)))
(stdout (get-buffer-create
(format " *apheleia-%s-stdout*" name)))
(stderr (get-buffer-create
(format " *apheleia-%s-stderr*" name))))
(dolist (buf (list stdout stderr))
(with-current-buffer buf
(erase-buffer)))
(condition-case-unless-debug e
(progn
(setq apheleia--current-process
(make-process
:name (format "apheleia-%s" name)
:buffer stdout
:stderr stderr
:command command
:noquery t
:sentinel
(lambda (proc _event)
(unless (process-live-p proc)
(with-current-buffer stderr
(when (= 0 (buffer-size))
(insert "[No output received on stderr]\n")))
(unwind-protect
(if (funcall
(or exit-status
(lambda (status)
(= 0 status)))
(process-exit-status proc))
(when callback
(funcall callback stdout))
(message
(concat
"Failed to run %s: exit status %s "
"(see hidden buffer%s)")
(car command)
(process-exit-status proc)
stderr))
(when ensure
(funcall ensure)))))))
(set-process-sentinel (get-buffer-process stderr) #'ignore)
(set-process-coding-system
apheleia--current-process
nil
(buffer-local-value 'buffer-file-coding-system stdin))
(when stdin
(process-send-string
apheleia--current-process
(with-current-buffer stdin
(buffer-string))))
(process-send-eof apheleia--current-process))
(error (message "Failed to run %s: %s" name (error-message-string e))))))
(defun apheleia--write-region-silently
(start end filename &optional
append visit lockname mustbenew write-region)
"Like `write-region', but silent.
START, END, FILENAME, APPEND, VISIT, LOCKNAME, and MUSTBENEW are
as in `write-region'. WRITE-REGION is used instead of the actual
`write-region' function, if provided."
(funcall (or write-region #'write-region)
start end filename append 0 lockname mustbenew)
(when (or (eq visit t) (stringp visit))
(setq buffer-file-name (if (eq visit t)
filename
visit))
(set-visited-file-modtime)
(set-buffer-modified-p nil)))
(defun apheleia--write-file-silently (&optional filename)
"Write contents of current buffer into file FILENAME, silently.
FILENAME defaults to value of variable `buffer-file-name'. Do not
mark the buffer as visiting FILENAME."
(cl-letf* ((write-region (symbol-function #'write-region))
((symbol-function #'write-region)
(lambda (start end filename &optional
append visit lockname mustbenew)
(apheleia--write-region-silently
start end filename append visit
lockname mustbenew write-region)))
(message (symbol-function #'message))
((symbol-function #'message)
(lambda (format &rest args)
(unless (equal format "Saving file %s...")
(apply message format args))))
;; Avoid triggering `after-set-visited-file-name-hook',
;; which can have various undesired effects in particular
;; major modes. Unfortunately, `write-file' triggers this
;; hook unconditionally even if the filename was not
;; changed, hence this hack :/
(run-hooks (symbol-function #'run-hooks))
((symbol-function #'run-hooks)
(lambda (&rest args)
(unless (equal args '(after-set-visited-file-name-hook))
(apply run-hooks args)))))
(write-file (or filename buffer-file-name))))
(defun apheleia--create-rcs-patch (old-buffer new-buffer callback)
"Generate RCS patch from text in OLD-BUFFER to text in NEW-BUFFER.
Once finished, invoke CALLBACK with a buffer containing the patch
as its sole argument."
;; Make sure at least one of the two buffers is saved to a file. The
;; other one we can feed on stdin.
(let ((old-fname
(with-current-buffer old-buffer
(and (not (buffer-modified-p)) buffer-file-name)))
(new-fname
(with-current-buffer new-buffer
(and (not (buffer-modified-p)) buffer-file-name))))
(unless (or old-fname new-fname)
(with-current-buffer new-buffer
(setq new-fname (make-temp-file "apheleia"))
(apheleia--write-region-silently (point-min) (point-max) new-fname)))
(with-current-buffer (get-buffer-create " *apheleia-patch*")
(erase-buffer)
(apheleia--make-process
:command `("diff" "--rcs" "--strip-trailing-cr" "--"
,(or old-fname "-")
,(or new-fname "-"))
:stdin (if new-fname old-buffer new-buffer)
:callback callback
:exit-status (lambda (status)
;; Exit status is 0 if no changes, 1 if some
;; changes, and 2 if error.
(memq status '(0 1)))))))
(defun apheleia--safe-buffer-name ()
"Return `buffer-name' without special file-system characters."
;; See https://stackoverflow.com/q/1976007 for a list of supported
;; characters on all systems.
(replace-regexp-in-string
(rx (or "/" "<" ">" ":" "\"" "\\" "|" "?" "*"))
""
(buffer-name)))
(defun apheleia--format-command (command &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.
STDIN-BUFFER is the optional buffer to use when creating a temporary
file for the formatters standard input.
If COMMAND uses the symbol `file' and the current buffer is modified
from what is written to disk, then return nil meaning meaning no
cmd is to be run."
(cl-block nil
(let ((input-fname nil)
(output-fname nil)
(stdin (or stdin-buffer (current-buffer)))
(npx nil))
;; TODO: Support arbitrary package managers, not just NPM.
(when (memq 'npx command)
(setq npx t)
(setq command (remq 'npx command)))
(unless (stringp (car command))
(error "Command cannot start with %S" (car command)))
(when npx
(when-let ((project-dir
(locate-dominating-file
default-directory "node_modules")))
(let ((binary
(expand-file-name
(car command)
(expand-file-name
".bin"
(expand-file-name
"node_modules"
project-dir)))))
(when (file-executable-p binary)
(setcar command binary)))))
(when (memq 'input command)
(let ((input-fname (make-temp-file
"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))
(setq command (mapcar (lambda (arg)
(if (eq arg 'input)
input-fname
arg))
command))))
(when (memq 'output command)
(setq output-fname (make-temp-file "apheleia"))
(setq command (mapcar (lambda (arg)
(if (eq arg 'output)
output-fname
arg))
command)))
(when (or (memq 'file command) (memq 'filepath command))
;; Fail when using file but not as the first formatter in this
;; sequence.
(when stdin-buffer
(error "Cannot run formatter using `file' or `filepath' in a \
sequence unless it's first in the sequence"))
(let ((file-name (or buffer-file-name
(concat default-directory
(apheleia--safe-buffer-name)))))
(setq command (mapcar (lambda (arg)
(when (eq arg 'file)
(setq stdin nil))
(if (memq arg '(file filepath))
(prog1 file-name
(when (buffer-modified-p)
(cl-return)))
arg))
command))))
;; 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
;; is. If the latter the contents of the list is substituted in
;; place.
(setq command
(cl-loop
for arg in command
with val = nil
do (setq val (if (stringp arg)
arg
(eval arg)))
if val
if (and (consp val)
(cl-every #'stringp val))
append val
else if (stringp val)
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))))
(defun apheleia--run-formatters (commands buffer callback &optional stdin)
"Run one or more code formatters on the current buffer.
The formatter is specified by the COMMANDS list. Each entry in
COMMANDS should be a list of strings or symbols (see
`apheleia-format-buffer'). BUFFER is the `current-buffer' when
this function was first called. Once all the formatters in
COMMANDS finish succesfully then invoke CALLBACK with one argument,
a buffer containing the output of all the formatters.
STDIN is a buffer containing the standard input for the first
formatter in COMMANDS. This should not be supplied by the caller
and instead is supplied by this command when invoked recursively.
The stdout of the previous formatter becomes the stdin of the
next formatter."
(when-let ((ret (with-current-buffer buffer
(apheleia--format-command (car commands) stdin))))
(cl-destructuring-bind (input-fname output-fname stdin &rest command) ret
(apheleia--make-process
:command command
:stdin (unless input-fname
stdin)
:callback
(lambda (stdout)
(when output-fname
;; Load output-fname contents into the stdout buffer.
(erase-buffer)
(insert-file-contents-literally output-fname))
(if (cdr commands)
;; Forward current stdout to remaining formatters, passing along
;; the current callback and using the current formatters output
;; as stdin.
(apheleia--run-formatters (cdr commands) buffer callback stdout)
(funcall callback stdout)))
:ensure
(lambda ()
(ignore-errors
(when input-fname
(delete-file input-fname))
(when output-fname
(delete-file output-fname))))))))
(defcustom apheleia-formatters
'((black . ("black" "-"))
(brittany . ("brittany"))
(clang-format . ("clang-format"))
(mix-format . ("mix" "format" "-"))
(gofmt . ("gofmt"))
(google-java-format . ("google-java-format" "-"))
(isort . ("isort" "--stdout" "-"))
(latexindent . ("latexindent"))
(ocamlformat . ("ocamlformat" "-" "--name" filepath))
(prettier . (npx "prettier" "--stdin-filepath" filepath))
(rustfmt . ("rustfmt" "--unstable-features" "--skip-children"
"--quiet" "--emit" "stdout"))
(terraform . ("terraform" "fmt" "-")))
"Alist of code formatting commands.
The keys may be any symbols you want, and the values are
commands, lists of strings and symbols.
In Lisp code, the format of commands is similar to what you pass to
`make-process', except as follows. Normally, the contents of the
current buffer are passed to the command on stdin, and the output
is read from stdout. However, if you use the symbol `file' as one
of the elements of commands, then the filename of the current
buffer is substituted for it. (Use `filepath' instead of `file'
if you need the filename of the current buffer, but you still
want its contents to be passed on stdin.) If you instead use the
symbol `input' as one of the elements of commands, then the
contents of the current buffer are written to a temporary file
and its name is substituted for `input'. Also, if you use the
symbol `output' as one of the elements of commands, then it is
substituted with the name of a temporary file. In that case, it
is expected that the command writes to that file, and the file is
then read into an Emacs buffer. Finally, if you use the symbol
`npx' as one of the elements of commands, then the first string
element of the command list is resolved inside node_modules/.bin if
such a directory exists anywhere above the current
`default-directory'."
:type '(alist
:key-type symbol
:value-type
(repeat
(choice
(string :tag "Argument")
(const :tag "Look for command in node_modules/.bin" npx)
(const :tag "Name of file being formatted" filepath)
(const :tag "Name of real file used for input" file)
(const :tag "Name of temporary file used for input" input)
(const :tag "Name of temporary file used for output" output)))))
(defcustom apheleia-mode-alist
'((cc-mode . clang-format)
(c-mode . clang-format)
(c++-mode . clang-format)
(css-mode . prettier)
(elixir-mode . mix-format)
(go-mode . gofmt)
(haskell-mode . brittany)
(html-mode . prettier)
(java-mode . google-java-format)
(js3-mode . prettier)
(js-mode . prettier)
(json-mode . prettier)
(latex-mode . latexindent)
(LaTeX-mode . latexindent)
(php-mode . nil)
(python-mode . black)
(ruby-mode . prettier)
(rustic-mode . rustfmt)
(rust-mode . rustfmt)
(sass-mode . prettier)
(terraform-mode . terraform)
(TeX-latex-mode . latexindent)
(TeX-mode . latexindent)
(tuareg-mode . ocamlformat)
(typescript-mode . prettier)
(web-mode . prettier)
(yaml-mode . prettier))
"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\\'\"."
:type '(alist
:key-type
(choice (symbol :tag "Major mode")
(string :tag "Buffer name regexp"))
:value-type
(choice (symbol :tag "Formatter")
(repeat
(symbol :tag "Formatter")))))
(defvar-local apheleia-formatter nil
"Name of formatter to use in current buffer, a symbol or nil.
If non-nil, then `apheleia-formatters' should have a matching
entry. This overrides `apheleia-mode-alist'.")
(defun apheleia--ensure-list (arg)
"Ensure ARG is a list of length at least 1.
When ARG is not a list its turned into a list."
(when arg
(if (listp arg)
arg
(list arg))))
(defun apheleia--get-formatter-commands (&optional interactive)
"Return the formatter commands to use for the current buffer.
This is a value suitable for `apheleia--run-formatters', or nil if
no formatter is configured for the current buffer. Consult the
values of `apheleia-mode-alist' and `apheleia-formatter' to
determine which formatter is configured.
If INTERACTIVE is non-nil, then prompt the user for which
formatter to run if none is configured, instead of returning nil.
If INTERACTIVE is the special symbol `prompt', then prompt
even if a formatter is configured."
(when-let ((formatters
(or (and (not (eq interactive 'prompt))
(apheleia--ensure-list
(or apheleia-formatter
(cl-dolist (entry apheleia-mode-alist)
(when (or (and (symbolp (car entry))
(derived-mode-p (car entry)))
(and (stringp (car entry))
buffer-file-name
(string-match-p
(car entry) buffer-file-name)))
(cl-return (cdr entry)))))))
(and interactive
(list
(intern
(completing-read
"Formatter: "
(or (map-keys apheleia-formatters)
(user-error
"No formatters in `apheleia-formatters'"))
nil 'require-match)))))))
(mapcar (lambda (formatter)
(or (alist-get formatter apheleia-formatters)
(user-error "No configuration for formatter `%S'"
formatter)))
formatters)))
(defun apheleia--buffer-hash ()
"Compute hash of current buffer."
(if (fboundp 'buffer-hash)
(buffer-hash)
(md5 (current-buffer))))
(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 buffer-file-name))
"Apheleia does not support remote files"))
;;;###autoload
(defun apheleia-format-buffer (commands &optional callback)
"Run code formatter asynchronously on current buffer, preserving point.
COMMANDS is a list of values from `apheleia-formatters'. 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-formatter-commands
(if current-prefix-arg
'prompt
'interactive)))))
(setq commands (apheleia--ensure-list commands))
;; Fail silently if disallowed, since we don't want to throw an
;; error on `post-command-hook'.
(unless (apheleia--disallowed-p)
(setq-local apheleia--buffer-hash (apheleia--buffer-hash))
(let ((cur-buffer (current-buffer)))
(apheleia--run-formatters
commands
cur-buffer
(lambda (formatted-buffer)
(with-current-buffer cur-buffer
;; Short-circuit.
(when (equal apheleia--buffer-hash (apheleia--buffer-hash))
(apheleia--create-rcs-patch
(current-buffer) formatted-buffer
(lambda (patch-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."
:type 'hook)
;; 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 apheleia-mode
(when-let ((commands (apheleia--get-formatter-commands)))
(apheleia-format-buffer
commands
(lambda ()
(with-demoted-errors "Apheleia: %s"
(when buffer-file-name
(let ((apheleia--format-after-save-in-progress t))
(apheleia--write-file-silently buffer-file-name)))
(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"
(if apheleia-mode
(add-hook 'after-save-hook #'apheleia--format-after-save nil 'local)
(remove-hook 'after-save-hook #'apheleia--format-after-save 'local)))
(define-globalized-minor-mode apheleia-global-mode
apheleia-mode apheleia-mode)
(put 'apheleia-mode 'safe-local-variable #'booleanp))
(provide 'apheleia)
;;; apheleia.el ends here