TL;DR
- Go開発を
go-ts-mode(tree-sitterベース)に寄せたら、問題が発生していた。 - 1つ目:
gofmt(goimportsで代替)を実行した直後は正しく整列されているのに、TABキーを1回押すとインデントが縮む。 - 2つ目:
before-save-hookにgofmt-before-saveを登録しても、保存時に一切フォーマットされない。しかもエラーは出ない。 - 両方とも「go-mode.el(tree-sitter以前のパッケージ)がgo-ts-modeの存在を想定していない」ことが根っこの原因だった。
環境
- Emacs、Go開発は
go-ts-mode(tree-sitterベース)を使用 - OS: Arch Linux
gofmt-commandは"goimports"に上書き設定(gofmtの代わりにgoimportsを使う)- 対象ファイル例:
internal/api/server.go(構造体フィールドやjsonタグを縦に整列させるコードスタイル)
試したこと
症状1: 手動でTABキーを押すとインデントが縮む
手動でM-x gofmtを実行して整形した直後のファイルは正しく整列されているが、既存行にカーソルを置いてTABキーを押すと、その行のインデントが浅くなる。
思い出したがこれはかなり前から存在してる不備だ。
ちょうどよかったのでここで直してみよう。
切り分けは以下の手順で進めた。
M-x describe-variable RET indent-tabs-mode RET→ 値はt。タブを使う設定として正常。- 対象行の文字を
C-u C-x =で確認 → タブ文字であることを確認(Char: TAB)。 - タブの表示幅を疑い、行頭でタブ1個分右に移動してから
C-x =→column=8。tab-widthも8(Emacsのデフォルト)。表示幅そのものは正常。 - 「短くなった行」と「正常に見える上の行」で
M-m(back-to-indentation)からのC-x =を比較 → 両方column=16で一致。インデント幅そのものは揃っていた。
ここで「タブ幅の表示問題ではない」と判明。
真因は次の通り。gofmtは構造体フィールドやjsonタグを縦に整列させるために、構文的に必要な数以上のタブを意図的に挿入する(整列目的の余分なタブ)。一方go-ts-modeはtree-sitterによる構文解析で「その行の構文的必要インデント深さ」だけを計算し、TABキー押下時に行頭の空白をその計算値に置き換える。gofmtが足した「整列用の余分なタブ」は構文的には不要なので、go-ts-modeがそれを認識せず削ってしまう。
これはEmacs設定のバグではなく仕様通りの動作だ。go-ts-modeには「整列のための余分な空白」を認識する機能がそもそもない。
調査の途中で、副原因ももう1つ見つかった。go-ts-mode-indent-offsetが4に設定されていた。
(use-package treesit
:straight (:type built-in)
:config
(setq treesit-font-lock-level 4)
(setq go-ts-mode-indent-offset 4))
go-ts-mode-indent-offset = 4だと「1インデントレベル=4列」で計算される。一方tab-widthはEmacsデフォルトの8のまま。gofmtの出力は「1インデントレベル=タブ1個(実質8列相当)」が前提なので、この不一致でTABキー押下時の再インデント計算が4列刻みでずれる。
対処はgo-ts-mode-indent-offsetをtab-widthと同じ値に揃えるだけ。
;; 変更前
(setq go-ts-mode-indent-offset 4)
;; 変更後(効果確認済み)
(setq go-ts-mode-indent-offset 8)
これで症状は解消した。
色々考えたがタブ表示幅は4のままにしたい
「表示幅は8より4が好み」という要望が自分の中にあった。結論としては、tab-widthとgo-ts-mode-indent-offsetの値が一致してさえいれば8である必要はない。両方4に揃えても問題ない。
懸念点として「tab-widthを変えるとgofmtの出力(タブ文字の個数)がおかしくならないか」という疑問が浮かんだが、これは誤解だった。gofmtはタブの表示幅(tab-width)を一切参照しない。gofmtの整列ロジックは常に「1インデントレベル=タブ文字1個」という文字ベースのルールで動作し、Emacs側の表示設定には依存しない。tab-widthはEmacsの表示専用の変数であり、ファイル内容(タブ文字そのものの個数)には一切影響しない。
最終的には以下に落ち着いた。実際に動作確認済み。
(setq-default tab-width 4)
(setq go-ts-mode-indent-offset 4)
症状2: 保存時の自動フォーマット(before-save-hook)が効かない
症状発覚時のコードはこうなっていた。
(setq gofmt-command "goimports")
(add-hook 'go-ts-mode-hook
(lambda ()
(add-hook 'before-save-hook #'gofmt-before-save nil t)))
(add-hook 'go-ts-mode-hook #'eglot-ensure)
(use-package go-mode
:config
(setq gofmt-command "goimports"))
(add-hook 'go-ts-mode-hook #'eglot-ensure)
(add-hook 'go-ts-mode-hook
(lambda ()
(add-hook 'before-save-hook #'gofmt-before-save nil t)))
go-ts-mode-hookへのeglot-ensure登録とgofmt-before-save登録がそれぞれ2重に書かれていたのは、後から見返すと単純にコピペ跡が残っていただけだった。
手動でM-x gofmtを実行すると正しく整形される。しかしC-x C-s(保存)だけではフォーマットされない。しかも*Messages*バッファにエラーは一切出ない。実行すると即死どころか、何も起きないので気づくのに時間がかかった。
切り分けはこう進めた。
which goimports→~/go/bin/goimports(シェルPATHには存在)。- Emacs内で
M-: (executable-find "goimports")→ 同じパスが返る。EmacsのPATH解決も正常。 - バッファのメジャーモードを確認 →
go-ts-modeになっていることを確認。 M-x describe-variable RET before-save-hook RET→ ローカル値に(eglot--signal-textDocument/willSave gofmt-before-save t)が登録されていることを確認。フック登録自体は正常。- 保存前後で
*Messages*を確認 → エラーなし、フォーマットもされない。 M-x find-function RET gofmt-before-save RETで関数定義元にジャンプ。中身は以下だった。
(when (eq major-mode 'go-mode) (gofmt))
原因はこれだった。gofmt-before-save(go-mode.el由来)の実装が(eq major-mode 'go-mode)という完全一致判定になっている。derived-mode-pではなくeqなので、go-ts-modeで開いている場合は条件を満たさず、gofmt(実体はgoimports呼び出し)が一切呼ばれずに素通りする。go-mode.el側がtree-sitter版モードの存在を考慮していない古い実装であることが根本原因だ。
対処として、自前のラッパー関数を定義し、derived-mode-pでgo-modeとgo-ts-modeの両方を判定させることにした。
(defun my/gofmt-before-save ()
"gofmt-before-save の go-ts-mode 非対応を回避するラッパー."
(when (derived-mode-p 'go-mode 'go-ts-mode)
(gofmt)))
登録側もこちらに差し替え、重複していたフック登録も1回に整理した。
(add-hook 'go-ts-mode-hook #'eglot-ensure)
(add-hook 'go-ts-mode-hook
(lambda ()
(add-hook 'before-save-hook #'my/gofmt-before-save nil t)))
go-modeブロックを消したら(void-function gofmt)
go-ts-modeに一本化しているつもりで、記事化作業中にuse-package go-modeブロック自体を削除した。すると保存時にBefore-save hook error: (void-function gofmt)というエラーが発生した。
原因は単純で、gofmt関数自体はgo-mode.elパッケージ内で定義されている。use-package go-modeを削除するとパッケージがロードされなくなり、go-modeは使っていなくてもgofmt関数だけは存在しなくなっていた。
対処は、go-modeパッケージはロードだけはするが、自動起動(モードとしての使用)はさせない設定にすること。
(use-package go-mode
:defer t)
:defer tによりパッケージはロードされる(=gofmt関数は使える)が、.goファイルを開いてもgo-modeが自動起動することはない。実際に使うのはgo-ts-modeのままだ。
この修正後、保存時に以下のメッセージが出て正常にフォーマットされることを確認した。
Calling gofmt: goimports (-srcdir ~/dev/myproject/internal/api/server.go -w /tmp/gofmtjqOqtQ.go)
結論:go-mode / go-ts-mode 対応マップ
| 項目 | go-mode | go-ts-mode(素の状態) | go-ts-mode(対処後) |
|---|---|---|---|
| gofmt後、TABキーでインデントが崩れない | ✅ | ❌ | ✅(indent-offsetをtab-widthに揃える) |
gofmt-before-saveが発火する |
✅ | ❌(eq判定でスルーされる) |
✅(derived-mode-pラッパーで対応) |
gofmt関数自体が使える |
✅ | ❌(go-mode未ロードだとvoid-function) |
✅(:defer tでロードのみ) |
現実的な代替案
go-ts-mode-indent-offsetをtab-widthと一致させる(値は8でも4でもよい。とにかく揃える)。gofmt-before-saveをそのまま使わず、derived-mode-pで判定する自前ラッパーをbefore-save-hookに登録する。go-modeパッケージは:defer tでロードだけ行い、モードとしては起動させない(gofmt関数を生かすため)。
最終的な設定
;; Tree-sitter設定内
(setq go-ts-mode-indent-offset 4) ; tab-widthと一致させること
;; Go
;; gofmt-before-save は (eq major-mode 'go-mode) を直接チェックしており
;; go-ts-mode では発火しないため、derived-mode-p で判定する自前関数を使う。
;; gofmt 関数自体は go-mode.el で定義されているため、go-mode 自動起動はさせず
;; パッケージのロードだけ行う。
(use-package go-mode
:defer t)
(setq gofmt-command "goimports") ; gofmtの代わりにgoimportsを使う
(defun my/gofmt-before-save ()
"gofmt-before-save の go-ts-mode 非対応を回避するラッパー."
(when (derived-mode-p 'go-mode 'go-ts-mode)
(gofmt)))
(add-hook 'go-ts-mode-hook #'eglot-ensure)
(add-hook 'go-ts-mode-hook
(lambda ()
(add-hook 'before-save-hook #'my/gofmt-before-save nil t)))
(別途tab-widthは(setq-default tab-width 4)などで揃えておくこと)
所感
動かない時はソースを直接読みに行くのが結局一番早い。M-x find-functionでgofmt-before-saveの中身を見るまで、まさかeqで完全一致判定しているとは思わなかった。tree-sitter系モードへの移行は便利な反面、周辺の古いパッケージが新モードの存在を想定していないケースが地味に転がっている。今回はたまたまderived-mode-pで回避できたが、他のgo-mode前提のパッケージでも同じ罠がありそうな気配はある。
参考
- go-mode.el 公式リポジトリ: dominikh/go-mode.el
- go-mode.el README(
gofmt-before-save等の機能説明): dominikh/go-mode.el/blob/master/README.md gofmt-before-saveをminor mode化する提案PR(未マージ、議論継続中): dominikh/go-mode.el/pull/426before-save-hook関連の過去Issue(内容は今回の症状と完全に一致するわけではなく、go-remove-unused-imports絡みの設定ミスに関するもの。フック周りで詰まった過去事例として参考まで): dominikh/go-mode.el/issues/106- Spacemacsでの
gofmt-before-save利用例(go layer): syl20bnr/spacemacs/pull/11082/files