Emacs 整合格式化工具 Prettier 如何判断有没有改动?

我想在 Emacs 中用 Prettier。假设要格式化 hello.js ,可用命令:

$ <hello.js npx prettier --stdin-filepath hello.js

翻译成 Emacs Lisp:

(defun chunyang-prettier ()
  "Prettier the current buffer."
  (interactive)
  (let ((buffer (generate-new-buffer " *prettier*")))
    (pcase (call-process-region
            nil nil "npx" nil buffer nil
            "prettier" "--stdin-filepath" buffer-file-name)
      (0
       (replace-buffer-contents buffer 1)
       (kill-buffer buffer))
      (exit-code
       (message "prettier failed with status %s" exit-code)
       (display-buffer buffer)))))

这可以工作,但有一个问题是就是文件格式完美,一点点改动都不需要,上面的 replace-buffer-contents 还是会执行,浪费时间和CPU,我注意到 Prettier 有个 --check 选项,可以知道文件是否完美无需格式化,但是运行两次 Prettier 命令应该更慢,光 time npx prettier -v 就花了 0.2s。

这应该是所有格式化工具集成到 Emacs 都会遇到的问题,大家有没有了解是如何处理的?

先保存,然后用 npx prettier --write hello.js 直接格式化文件,最后在 Revert 貌似可行,真有这么操作的?

我也认为应该在 after-save-hook,然后 auto-revert

计算格式化前后内容的hash 只要hash变了 就更新buffer

自己写一个 cli?

在 cli 里边 check,如果不一样就继续执行 format。这样应该比从 Emacs 调用两次更快一些。

2赞

格式化代码 https://github.com/lassik/emacs-format-all-the-code 这个包感觉也挺好用的

瞄了一下源代码,这个 check 太简单粗暴了:

就是直接两个字符串对比啊。

我原本还想说,是不是可以给作者提意见,让它给 format 加一个返回码,表示文件没有改动。现在看来,整个过程应该是:先把输入文件 parse 到内存,然后重新按规则打印出来,所以它自己也不知道文件有没有改动,所以才需要对比输入和输出。

check 只不过是一次没有输出的 format 罢了。

因此完全可以自己实现一个 cli,在里边 format,然后检查输入和输出有无变化,增加一个返回码。


又看了一下 cli 的实现,它其实已经比较过输入输出有无变化了:

可以在下边这段里增加一个返回码?

https://github.com/prettier/prettier/blob/master/src/cli/util.js#L505-L518

用 emacs-prettier 会方便一点,直接执行 prettier-js 然后如果有改变,image

我(一个 Node 小白)试了试,如果没改动,返回 100,有改动,打印结果,并返回 0:

#!/usr/bin/env node

const fs = require("fs");
const prettier = require("prettier");

const source = fs.readFileSync(0, "utf8");
const formatted = prettier.format(source, { parser: "babel" });

// 100 估计没被占用
if (source === formatted) process.exit(100);

process.stdout.write(formatted);

Emacs 设置跟上面差不多,就是多个检测 100 的环节

(defun chunyang-my-prettier ()
  "Prettier the current buffer."
  (interactive)
  (let ((buffer (generate-new-buffer " *my-prettier*")))
    (pcase (call-process-region
            nil nil "my-prettier" nil buffer)
      (100 (message "Already formatted"))
      (0
       (replace-buffer-contents buffer 1)
       (kill-buffer buffer))
      (exit-code
       (message "prettier failed with status %s" exit-code)
       (display-buffer buffer)))))

这样应该就可以了,不需要完整的 cli 实现。

可以用erase-bufferinsert-buffer-substring代替replace-buffer-contents. insert-buffer-substring是c函数,应该会好一些。

我自己的代码格式化实现是把格式化后的代码先放在临时的buffer里,和目标的buffer用compare-buffer-substrings做比较。如果不一样,才替换目标buffer,并且把point放到同样的位置。这样buffer的修改状态和point也尽可能保留。

    (defun format-buffer-with-exec (exec &rest opts)
      "Format current buffer using external EXEC along with its
    OPTS. It returns numeric status code of the command execution
    result."
      (let ((old-buf (current-buffer))
            (fmt-buf "*Format Buffer Output*") status)
        (save-restriction
          (widen)
          (with-current-buffer (get-buffer-create fmt-buf)
            (let ((inhibit-read-only t))
              (erase-buffer)
              (message "Formatting buffer using %s..." exec)
              (with-current-buffer old-buf
                (setq status
                      (apply #'call-process-region
                             (append
                              (list nil nil exec nil fmt-buf nil) opts))))))
          (if (zerop status)
              (unwind-protect
                  (if (zerop (compare-buffer-substrings
                              old-buf nil nil fmt-buf nil nil))
                      (message "Already formatted")
                    (let ((p (point)))
                      (erase-buffer)
                      (insert-buffer-substring fmt-buf)
                      (goto-char p))
                    (message "Formatting buffer using %s...done" exec))
                (let ((fmt-win (get-buffer-window fmt-buf)))
                  (if fmt-win (delete-window fmt-win))
                  (kill-buffer fmt-buf)))
            (with-current-buffer fmt-buf
              (let ((inhibit-read-only t))
                (goto-char (point-min))
                (insert (format "Command '%s %s'\n\n" exec (string-join opts " ")))
                (special-mode)
                (display-buffer fmt-buf)
                (message "Failed to format buffer using %s" exec)))))
        status))

    (defun format-buffer-cmdline ()
      "Return command line for external tool used to format code if
    configured, or nil if not."
      (cond ((memq major-mode (list 'c-mode 'c++-mode))
             (list "astyle" "--style=java"
                   "--convert-tabs"
                   "--indent=spaces=4"
                   "--indent-col1-comments"
                   "--align-pointer=name"
                   "--align-reference=name"
                   "--attach-namespaces"
                   "--attach-classes"
                   "--attach-inlines"
                   "--attach-extern-c"
                   "--add-brackets"
                   "--pad-oper" "--pad-header" "--mode=c"))
            ((equal major-mode 'java-mode)
             (list "astyle" "--style=java"
                   "--convert-tabs"
                   "--indent=spaces=4"
                   "--indent-col1-comments"
                   "--align-pointer=name"
                   "--align-reference=name"
                   "--attach-namespaces"
                   "--attach-classes"
                   "--attach-inlines"
                   "--attach-extern-c"
                   "--add-brackets"
                   "--pad-oper" "--pad-header" "--mode=java"))
            ((equal major-mode 'nxml-mode)
             (list "tidy" "-indent" "-wrap" "0" "-omit" "-quiet" "-utf8"))
            ((equal major-mode 'python-mode)
             (list "autopep8" "--max-line-length=100" "--aggressive" "-"))
            ((equal major-mode 'json-mode)
             (list "jq" "--monochrome-output" "--indent" "2" "."))
            ((equal major-mode 'jsonnet-mode)
             (list "jsonnetfmt" "-"))
            ((equal major-mode 'terraform-mode)
             (list "terraform" "fmt" "-no-color" "-"))
            (t nil)))

调用(format-buffer-with-exec (format-buffer-cmdline))即可

这两种方法效果不完全等同,后者会保留 pointer marker overlay 等信息。

如果格式化得到的代码不变,这个函数费时间,需要一个字符一个字符比较。当然一般代码文件都不大,也没什么问题。

Prettier 有个 --cursor-offset 专门处理这个问题,但要输出 JSON,所以需要 Emacs 这里 Parse JSON,,我不考虑这个方法。