👻

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の組み合わせには既知の問題があるっぽい?

lsp-mode #3555

😢 この組み合わせは諦めることに。

試したこと その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対応のフォーマッターを自作することにしました。
ポイントは以下の通り:

  1. process-file vs call-process: TRAMP対応にはprocess-fileが必要
  2. default-directory: リモートのディレクトリに設定することでTRAMPコンテキストが有効になる
  3. file-local-name: TRAMPのプレフィックスを除いたパスを取得
  4. カーソル位置の保持: フォーマット前の位置を記憶して復元
  5. 一時ファイルの場所: リモートの場合はリモートに作成
;; 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