最近チームの同僚がScalaのコードベースにscalafmtでフォーマッタをかけてくれて、ソースコードの見通しがとても良くなった。
VSCodeはセーブ時に自動的にLSP経由でコードフォーマットを呼び出す機能(format-on-save)を持っているのだが、コードベース全体がフォーマットされたことで、この設定を有効化できるようになった。 とてもありがたい。
ところでEmacsにもLSPプラグインがあるので、LSP経由でのフォーマット機能は存在するのだが、セーブ時に自動的にフォーマットする機能はないようだ。
そこで、セーブ時に自動的にフォーマッタをLSP経由で呼び出してくれる機能をEmacs Lispで書いた。ディレクトリ・モード単位で有効・無効の設定が可能な設計にした。
コード
まずは、以下のコードを.emacs
などに置く。
(defvar project/format-on-save nil) (defun my/get-project-value (key) ;; consider setproject-local variables on `.dir-locals.el` (intern-soft (concatenate 'string "project/" (symbol-name key)))) (defun do-lsp-format-on-save () (interactive) (when (my/get-project-value 'format-on-save) (lsp-format-buffer))) (add-hook 'before-save-hook #'do-lsp-format-on-save)
次に、自動フォーマットを有効化したいプロジェクトのトップに.dir-locals.el
というファイル名で以下のように記述する。
((scala-mode . ((project/format-on-save . t))))
すると、このファイルを置いたディレクトリ以下のScalaモードが起動する全てのファイルで、保存時にlsp-format-buffer
が呼び出されるようになる。
注意点として、初めてscalaファイルを開いたときに「このローカル変数を有効化しますか」と尋ねてくるので、「!」を選択して常に有効化状態にする必要がある。二度目以降はこのメッセージは表示されない。
仕組み
このコードは、LSPのフォーマット機能と、before-save-hook
という仕組みと、Local variablesという仕組みを組み合わせて実現されている。
LSPのフォーマット機能
Emacsのlsp-modeでは、lsp-format-buffer
関数が提供されており、呼び出したバッファをLSPでフォーマットしてくれる。
なので基本的にどうフォーマットするかは全部任せておいてよい。自動的に保存まではしてくれないので、保存直前に呼び出す必要がある。
before-save-hook
Emacsには標準でbefore-save-hook
というフックが用意されており、その名の通り保存直前に呼び出される処理を定義できる。
add-hook
を使って関数を登録することにより、保存直前に特定の関数を呼び出すということが可能である。これと先述のlsp-format-buffer
とを組み合わせることで、あらかじめフォーマットした状態で保存することができる。
Local variables
ちょっとニッチであまり知られていない機能だが、Local variablesというEmacsの標準機能を使うことで、特定のディレクトリ以下にいるときに特定の変数を特定の値に設定することが可能である。VSCodeでいうところの.vscode
ディレクトリみたいな機能である。
Emacsでは.dir-locals.el
というファイルに特定の書式(分かる人には分かるだろうが、alist*1のalistだ)でS式を書くことで、そのファイル以下の全ファイルでその変数が束縛される。
ただし全ファイルで一様に変数を束縛すると不都合があるので、モードを指定して束縛することができるという大きな特徴がある。
そこで今回は、自動フォーマットを有効にするか無効にするかの設定変数を定義し、scala-mode
の時に限って自動フォーマットを有効化するようなlocal variablesを定義した。他のモード、例えばTypeScriptでも有効化したいのであればモード名だけ変えた同じ設定を増やせばよい。
そして、先述のフックからはこの変数を読み取って、必要な場合にのみフォーマットを呼び出すようにした。デフォルト値はnil
に設定し、ユーザが明に有効化したときにのみ動作するようにした。
変数衝突を避ける
あまり一般的な名前で変数定義しても衝突のおそれがあるため、project/
という名前をprefixすることにした。似たような自動化の仕組みを増やしたいときは、同じようにproject/
でprefixしようと思っている。(ちょっと早すぎた最適化感もあるが)
また、関数my/get-project-value
で使われているintern-soft
は、「所与の名前でシンボルを探し、発見できたらそれを、発見できなければnilを返す」という便利な関数である。