⚠️ この記事はアイデア出しを筆者が行い、文章の生成に生成AIを使用しています。内容のレビューは筆者が行っています。

TL;DR

  • Go開発をgo-ts-mode(tree-sitterベース)に寄せたら、問題が発生していた。
  • 1つ目: gofmt(goimportsで代替)を実行した直後は正しく整列されているのに、TABキーを1回押すとインデントが縮む
  • 2つ目: before-save-hookgofmt-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キーを押すと、その行のインデントが浅くなる。 思い出したがこれはかなり前から存在してる不備だ。

ちょうどよかったのでここで直してみよう。

切り分けは以下の手順で進めた。

  1. M-x describe-variable RET indent-tabs-mode RET → 値はt。タブを使う設定として正常。
  2. 対象行の文字をC-u C-x =で確認 → タブ文字であることを確認(Char: TAB)。
  3. タブの表示幅を疑い、行頭でタブ1個分右に移動してからC-x =column=8tab-width8(Emacsのデフォルト)。表示幅そのものは正常。
  4. 「短くなった行」と「正常に見える上の行」で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-offset4に設定されていた。

(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-offsettab-widthと同じ値に揃えるだけ。

;; 変更前
(setq go-ts-mode-indent-offset 4)
;; 変更後(効果確認済み)
(setq go-ts-mode-indent-offset 8)

これで症状は解消した。

色々考えたがタブ表示幅は4のままにしたい

「表示幅は8より4が好み」という要望が自分の中にあった。結論としては、tab-widthgo-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*バッファにエラーは一切出ない。実行すると即死どころか、何も起きないので気づくのに時間がかかった。

切り分けはこう進めた。

  1. which goimports~/go/bin/goimports(シェルPATHには存在)。
  2. Emacs内でM-: (executable-find "goimports") → 同じパスが返る。EmacsのPATH解決も正常。
  3. バッファのメジャーモードを確認 → go-ts-modeになっていることを確認。
  4. M-x describe-variable RET before-save-hook RET → ローカル値に(eglot--signal-textDocument/willSave gofmt-before-save t)が登録されていることを確認。フック登録自体は正常。
  5. 保存前後で*Messages*を確認 → エラーなし、フォーマットもされない。
  6. 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-pgo-modego-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でロードのみ)

現実的な代替案

  1. go-ts-mode-indent-offsettab-widthと一致させる(値は8でも4でもよい。とにかく揃える)。
  2. gofmt-before-saveをそのまま使わず、derived-mode-pで判定する自前ラッパーをbefore-save-hookに登録する。
  3. 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-functiongofmt-before-saveの中身を見るまで、まさかeqで完全一致判定しているとは思わなかった。tree-sitter系モードへの移行は便利な反面、周辺の古いパッケージが新モードの存在を想定していないケースが地味に転がっている。今回はたまたまderived-mode-pで回避できたが、他のgo-mode前提のパッケージでも同じ罠がありそうな気配はある。

参考