EmacsのTRAMP経由でBiomeを使ってフォーマットする
最近、プロジェクトでPrettierからBiomeに移行しました。
ローカルではすんなりと動いたのですが、TRAMP経由でリモートサーバーのファイルを編集している時にフォーマットが効かない…。
私はTRAMP経由での開発が中心なので、これは結構困る問題でした。
TypeScriptのコード補完もBiomeのフォーマットも両方欲しい、そんな欲張りな要求を叶えるまでの試行錯誤をまとめます。
環境
- Emacs 30.1
- Corfu + Cape(Company-modeから移行済み)
- macOS (ローカル)
- Ubuntu (リモートサーバー)
- Node.js v24.2.0 (nvm管理)
- Biome 2.2.4
試したこと その1: lsp-mode + lsp-biome
最初は素直にlsp-modeでlsp-biomeを使おうとしました。
(use-package lsp-mode
:ensure t
:hook (web-mode . lsp-deferred))
ローカルでは問題なく動作。しかしTRAMP経由だと…
LSP :: The following servers support current file but do not have automatic installation...
ts-ls-tramp jsts-ls-tramp...
TypeScriptのLanguage Serverが見つからないと言われる。
TRAMPのリモートパスを設定して、ts-ls-trampクライアントを登録し直してみたり:
(use-package tramp
:config
(add-to-list 'tramp-remote-path "/home/xxx/.nvm/versions/node/v24.2.0/bin"))
(use-package lsp-mode
:config
;; TRAMP用のTypeScriptサーバー設定を試行錯誤
(lsp-register-client
(make-lsp-client :new-connection (lsp-tramp-connection "typescript-language-server --stdio")
:major-modes '(web-mode)
:remote? t
:server-id 'ts-ls-tramp)))
これでなんとか起動はしたものの、肝心の補完候補が出てこない。
*Messages*バッファにはCapeやCorfuのエラーが頻発。
私の環境ではcompany-modeから Corfu + Cape に移行していて、普段はこの組み合わせで快適に補完が効いているのですが、調べてみると、lsp-mode + Corfu + TRAMPの組み合わせには既知の問題があるっぽい?
😢 この組み合わせは諦めることに。
試したこと その2: Eglot + biome lsp-proxy
次はEglotを試してみました。Eglotは標準でTRAMPをサポートしているし、期待が持てます。
(use-package eglot
:ensure t
:config
(add-to-list 'eglot-server-programs
'(web-mode . ("biome" "lsp-proxy")))
:hook
(web-mode . eglot-ensure))
しかし、ここで重要な問題に気づきました。
Eglotは1つのバッファで1つのLSPサーバーしか使えない...
つまり、TypeScript補完用のtypescript-language-serverとフォーマット用のbiome lsp-proxyを同時に使うことができない…。
どちらか一つを選ぶ必要があるので、TypeScript Language Serverを優先することにしました。
試したこと その3: emacs-reformatter
Biomeのlsp-proxyが使えないなら、補完とフォーマットを分離するしかない!まずはフォーマッターの既存パッケージを調査しました。
emacs-reformatterは任意のフォーマッターをEmacsに統合するためのパッケージで、prettierやblackなど多くのフォーマッターで使われています。
(use-package reformatter
:ensure t
:config
(reformatter-define biome-format
:program "biome"
:args '("format" "--stdin-file-path" input-file)))
しかし、emacs-reformatterはTRAMP未対応でした。リモートファイルを編集していると、ローカルでbiomeコマンドを実行しようとしてエラーに…。
試したこと その4: TypeScript Language Server + カスタムフォーマッター
既存パッケージがダメなら、自作するしかない!
ほぼClaudeCodeに書いてもらいました。
- 補完: Eglot + typescript-language-server(Corfu + Capeでの補完UI)
- フォーマット: カスタム関数でBiome実行
(use-package eglot
:ensure t
:config
(add-to-list 'eglot-server-programs
'(web-mode . ("typescript-language-server" "--stdio")))
:hook
(web-mode . eglot-ensure))
これで補完は動くようになりました。次はフォーマッターの自作です。
最終的な解決策: カスタムフォーマッター実装
結局、TRAMP対応のフォーマッターを自作することにしました。
ポイントは以下の通り:
-
process-file vs call-process: TRAMP対応には
process-fileが必要 - default-directory: リモートのディレクトリに設定することでTRAMPコンテキストが有効になる
- file-local-name: TRAMPのプレフィックスを除いたパスを取得
- カーソル位置の保持: フォーマット前の位置を記憶して復元
- 一時ファイルの場所: リモートの場合はリモートに作成
;; TRAMPのリモートパスにBiomeの場所を追加
(use-package tramp
:config
(add-to-list 'tramp-remote-path "/home/xxx/.nvm/versions/node/v24.2.0/bin"))
;; Custom Biome formatter with TRAMP support
(defun my/biome-format-buffer ()
"Format current buffer with Biome (TRAMP compatible)."
(interactive)
(let* ((file (buffer-file-name))
(content (buffer-string))
(is-remote (file-remote-p file))
(output-buffer (get-buffer-create "*Biome Format Debug*")))
(if is-remote
;; リモートファイルの場合: リモートで一時ファイルを作成・処理
(let* ((remote-dir (file-name-directory file))
(remote-temp-file (concat remote-dir ".biome-tmp-"
(format-time-string "%Y%m%d%H%M%S")
(and file (file-name-extension file t))))
(default-directory remote-dir)) ; TRAMPコンテキストを設定
(unwind-protect
(progn
(write-region content nil remote-temp-file nil 'silent)
(let ((exit-code (process-file "biome" nil output-buffer t
"format" "--write"
(file-local-name remote-temp-file))))
(if (zerop exit-code)
(let ((point-before (point)))
(erase-buffer)
(insert-file-contents remote-temp-file)
(goto-char (min point-before (point-max)))
(message "Biome format successful (TRAMP)"))
(message "Biome format failed (TRAMP). Check *Biome Format Debug* buffer"))))
(ignore-errors (delete-file remote-temp-file))))
;; ローカルファイルの場合
(let ((temp-file (make-temp-file "biome-format" nil
(and file (file-name-extension file t))))
(point-before (point)))
(unwind-protect
(progn
(write-region content nil temp-file nil 'silent)
(let ((exit-code (call-process "biome" nil output-buffer t
"format" "--write" temp-file)))
(if (zerop exit-code)
(progn
(erase-buffer)
(insert-file-contents temp-file)
(goto-char (min point-before (point-max)))
(message "Biome format successful (local)"))
(message "Biome format failed (local). Check *Biome Format Debug* buffer"))))
(delete-file temp-file))))))
;; 保存時に自動フォーマット
(add-hook 'web-mode-hook
(lambda ()
(when (and (buffer-file-name)
(string-match-p "\\.\\(ts\\|tsx\\)\\'" (buffer-file-name)))
(add-hook 'before-save-hook #'my/biome-format-buffer nil t))))
デバッグのコツ
うまく動かない時は、デバッグ情報を出力するのが大事でした:
(process-file "which" nil output-buffer t "biome")
(process-file "sh" nil output-buffer t "-c" "echo $PATH")
これでリモート環境でコマンドが見つかるか確認できます。
まとめ
TRAMP経由でのBiomeフォーマッター実装は思った以上に罠が多かったです。
素直にVSCode使えばいいんですけどね、やっぱりEmacsでなんとかしたくなるんですよね。
Discussion