5

While there are packages that format on save, I'd like to be able to run a custom command that auto-formats a file on save.

Upon saving it runs an external process that:

  • Takes the buffer as stdin.
  • Outputs to a temporary file.

After that:

  • The output is used to replace the current buffer which is then saved.

  • Any errors from the stderr are reported as errors.

  • Any output from the stdout is printed as messages.

For the purpose of testing, this could be the auto-formatting command:

It converts the text to title-caps.

python -c "with open(__import__('sys').argv[-1], 'w') as fh: fh.write(__import__('sys').stdin.read().title())" -- /tmp/TEMP_FILE_FROM_EMACS.txt

... where /tmp/TEMP_FILE_FROM_EMACS.txt is a generated temp file name.


I realize this may be an involved answer. If this seems like too much hassle to answer, I'll investigate and post an answer myself, since I think it's useful to have a general function to handle this.

ideasman42
  • 8,375
  • 1
  • 28
  • 105

1 Answers1

5

This can be done using:

  • call-process-region to run the command and catch the stdout/stderr.
  • replace-buffer-contents to update the region without causing the entire buffer to be replaced.

    This is important to avoid this to be seen as one very large undo-step which risks loosing your undo history for example.

This code used clang-format package as a reference.

(defun mycustom-fmt-buffer ()
  (interactive)
  (let ((this-buffer (current-buffer))
        (my-command "python")
        (temp-buffer (generate-new-buffer " *mycustom-fmt*"))
        ;; Use for format output or stderr in the case of failure.
        (temp-file (make-temp-file "mycustom-fmt" nil ".el"))
        ;; Always use 'utf-8-unix' & ignore the buffer coding system.
        (default-process-coding-system '(utf-8-unix . utf-8-unix)))
    (condition-case err
        (unwind-protect
            (let ((status
                   (progn
                     (apply
                      #'call-process-region nil nil my-command nil
                      ;; stdout is a temp buffer, stderr is file.
                      `(,temp-buffer ,temp-file) nil
                      ;; arguments.
                      `
                      ("-c"
                       "with open(__import__('sys').argv[-1], 'w') as fh: fh.write(__import__('sys').stdin.read().title())"
                       ,temp-file))))
                  (stderr
                   (with-temp-buffer
                     (unless (zerop (cadr (insert-file-contents
                                           temp-file)))
                       (insert ": "))
                     (buffer-substring-no-properties
                      (point-min) (point-max)))))
              (cond
               ((stringp status)
                (error "(mycustom-fmt killed by signal %s%s)"
                       status stderr))
               ((not (zerop status))
                (error "(mycustom-fmt failed with code %d%s)"
                       status stderr))
               (t
                ;; Include the stdout as a message,
                ;; useful to check on how the program runs.
                (let ((stdout
                       (with-current-buffer temp-buffer
                         (buffer-substring-no-properties
                          (point-min) (point-max)))))

                  (unless (string-equal stdout "")
                    (message "%s" stdout)))))
              ;; Load the temp file into a temp buffer
              ;; & replace this-buffers contents.
              (with-temp-buffer
                (insert-file-contents temp-file)
                (let ((temp-buffer (current-buffer)))
                  (with-current-buffer this-buffer
                    (replace-buffer-contents temp-buffer))))))
      ;; Show error as message, so we can clean-up below.
      (error (message "%s" (error-message-string err))))

    ;; Cleanup.
    (delete-file temp-file)
    (when (buffer-name temp-buffer)
      (kill-buffer temp-buffer))))


(defun mycustom-fmt-save-hook-for-this-buffer ()
  (add-hook 'before-save-hook
            (lambda ()
              (progn
                (mycustom-fmt-buffer)
                ;; Continue to save.
                nil))
            nil
            ;; Buffer local hook.
            t))


;; Example for elisp, could be any mode though.
(add-hook 'emacs-lisp-mode-hook
          (lambda () (mycustom-fmt-save-hook-for-this-buffer)))
ideasman42
  • 8,375
  • 1
  • 28
  • 105
  • I am confused. Your code appears to use the same filename (the one assigned to `temp-file` in the outermost `let` of `mycustom-fmt-buffer`) for both stderr of the process and the file argument to the formatting command which will determine where the formatted code is written. There seems to be potential for a damaging clash there; am I missing something? Otherwise, thanks so much for this framework. – Glen Whitney Feb 02 '22 at 06:26
  • (I mean, in case of a formatting program that (say) writes warnings to stderr even in case of the formatting succeeding in writing to the named file. Maybe your code is presuming that if anything is written to stderr, the formatting program will write nothing to its designated output file?) – Glen Whitney Feb 02 '22 at 06:29
  • @glen-whitney I'll need to check on handling of stderr, wrote this answer a while back when I was fairly new to this area of the API. Writing to the stderr without exiting with a non-zero exit code may indeed include text that it shouldn't - OTOH, this shouldn't be too difficult to correct – ideasman42 Feb 28 '22 at 21:58