【Emacs】 Docker 上で Go 開発環境を作ろうとしてハマった(ている)話

近頃お仕事で Go を書くことになり、Mac 上で Emacs + LSP(サーバ: gopls, クライアント: eglot)な環境を作りました。これには下記の記事に全面的に依拠しております。

そして、割と良い感じに動いているので、1年ぐらい前に作って放置していた Docker 開発環境にも反映させるか...と思ってだいぶハマったというのが本記事の内容です。一応の解決はしたつもりですが、どう見てもアドホックな方法なので、何か知っている人がいたら教えて欲しいというぐらいの精度です。

Docker 上では下記OSをゲストとして利用しています。

なお、現状確認できているのは、Mac 上に直接インストールした Emacs では問題なかったということと、Docker 上の Alpine Linux で問題があったということだけなので、実は Docker は関係ないかもしれないです。



問題1: eglot がインストールできない

現象

通常は (pacakge-install 'eglot) で何事もなく終わるはずの eglot (LSP クライアント)のインストールですが...以下のエラーでインストールに失敗しました。

error: Package `jsonrpc-1.0.7' is unavailable

なお、これを後述の方法で解決すると、今度は次のエラーで怒られるので同様に対処が必要です。

error: Package `flymake-1.0.5' is unavailable

再現手順

下記の手順で再現します。

$ docker run --name alp -it alpine:3.10 /bin/sh
# - 以下コンテナ内の操作 - #
$ cd
$ apk add --no-cache emacs
$ mkdir .emacs.d
$ cat<<EOF>.emacs.d/init.el
(require 'package nil t)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(package-initialize)
(package-refresh-contents)
(package-install 'eglot)
EOF

ここで、Emacs を起動すると *Warning* バッファに先程のエラーが表示されます。

$ emacs -nw

調査

調べてみたものの、類似の現象として straight.el という別パッケージでの下記 issue が引っかかったぐらいでした。

Can't install elpa package jsonrpc · Issue #293 · raxod502/straight.el

この issue によると、下記のような経緯で jsonrpc パッケージが見つからないようになってエラーになったようです。

  1. 当初は jsonrpc は独立したパッケージとして開発されていた
  2. これが Emacs 本体に取り込まれることになった
  3. そして、MELPA (Emacs のパッケージリポジトリの一つ) も本体に jsonrpc があることを期待するようになった
  4. しかし、Emacs 側はまだ開発段階であったので、2 の反映には次のバージョンアップを待つ必要があった

しかし、解決方法が straight.el 固有なものであることと、現在 (Emacs 26.2) では上記4の状況は解消されていることから、今回の現象の解決には利用できません。

ただ、jsonrpc にしても flymake にしても、Emacs 本体に存在するものであるという共通点はあり、原因の根はそこにありそうです。

追記:解決

記事を上げた後、Common Lisp 開発環境のcl-devel2 でビルドエラーが起きていたので調べていたのですが、ふと下記のメッセージが目につきました *1

Failed to download ‘gnu’ archive.

とりあえず、gnu アーカイブのURLを明示的に指定してみました。

$ docker run --name alp -it alpine:3.10 /bin/sh
# - 以下コンテナ内の操作 - #
$ cd
$ apk add --no-cache emacs
$ mkdir .emacs.d
$ cat<<EOF>.emacs.d/init.el
(require 'package nil t)
;; ----- ↓ これ ----- ;;
(add-to-list 'package-archives '("gnu" . "http://elpa.gnu.org/packages/"))
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(package-initialize)
(package-refresh-contents)
(package-install 'eglot)
EOF
$ emacs -nw

通りました

Leaving directory `/root/.emacs.d/elpa/flymake-1.0.8'
^L
Compiling file /root/.emacs.d/elpa/flymake-1.0.8/flymake.el at Mon Jul 15 07:17:11 2019
Entering directory `/root/.emacs.d/elpa/flymake-1.0.8/'
^L
Compiling no file at Mon Jul 15 07:17:12 2019
Leaving directory `/root/.emacs.d/elpa/jsonrpc-1.0.7'
^L
Compiling file /root/.emacs.d/elpa/jsonrpc-1.0.7/jsonrpc.el at Mon Jul 15 07:17:12 2019
Entering directory `/root/.emacs.d/elpa/jsonrpc-1.0.7/'
^L
Compiling no file at Mon Jul 15 07:17:12 2019
Leaving directory `/root/.emacs.d/elpa/eglot-20190702.2158'
^L
Compiling file /root/.emacs.d/elpa/eglot-20190702.2158/eglot.el at Mon Jul 15 07:17:12 2019
Entering directory `/root/.emacs.d/elpa/eglot-20190702.2158/'
Warning (bytecomp): Unused lexical variable `desc'
Warning (bytecomp): Unused lexical variable `file1'

ただ、*Messages* バッファの方には依然として Failed to download ‘gnu’ archive. が出ているので、きっかけにはなりましたが別の現象のようです。

Obsolete: 解決?

上記の原因の根と思われるものの解明には至っておらず...力業で解決しました。ローカルに jsonrpc.el, flymake.el を落としてきて手ずからインストールする方法です。

まず、適当なフォルダに両ファイルを落としてきます(実際には下記に相当する操作を Dockerfile 側に書いています)。

$ emacs_src="~/.emacs.d/src/"
$ mkdir ${emacs_src}
$ cd ${emacs_src}
$ wget https://raw.githubusercontent.com/emacsmirror/jsonrpc/master/jsonrpc.el
$ wget https://raw.githubusercontent.com/emacsmirror/flymake/master/flymake.el

次に ~.emacs.d/init.el に下記の記述を加えて、両パッケージをインストールします*2。なお、各 .el を単に load-path するだけではパッケージマネージャが認識してくれないので不十分です。

(dolist (pack-name '("jsonrpc" "flymake"))
  (package-install-file
   (concat "~/.emacs.d/src/" pack-name ".el")))

これで、(package-install 'eglot) が無事通るようにはなり、eglot も無事動作しているようです。

問題2: gofmt が効かない

現象(・再現方法)

セーブ時に自動で goimports をかけるためには、下記のようにフックを設定します。

(use-package go-mode
  :commands go-mode
  :config
  (setq gofmt-command "goimports")
  (add-hook 'before-save-hook 'gofmt-before-save))

しかし、これまた Mac では起きていなかったエラーが発生しました...。

Invalid rcs patch or internal error in go--apply-rcs-patch

調査

取りあえずエラーメッセージでググってみたものの... go-mode.el ソースのエラーメッセージぐらいしかヒットしませんでした。

https://github.com/dominikh/go-mode.el/blob/35f6826e435c3004dabf134d0f2ae2f31ea7b6a2/go-mode.el#L1038 *3

(defun go--apply-rcs-patch (patch-buffer)
  "Apply an RCS-formatted diff from PATCH-BUFFER to the current buffer."
...
          (unless (looking-at "^\\([ad]\\)\\([0-9]+\\) \\([0-9]+\\)")
            (error "Invalid rcs patch or internal error in go--apply-rcs-patch"))
...

goimports した前後の差分をチェックして適用するみたいな動作をしているようです。チェックで落ちているようですが原因は良く分かりません。

解決?

マイナーっぽい問題をマジメに調査するのも辛いな...と思い、 gomimports をかける関数を自作することにしました。アドホックですね。

下記の my-gofmt-hookbefore-save-hook に設定して、セーブ前に動作させます。フック内でいったんセーブ(save-buffer)してしまってから goimports -w で直接ファイルを書き換えるという乱暴な方法をとっています*4。なお、フックをかけたままセーブしようとすると無限に save-buffer してしまうので、いったんフックを外してから最後にフックを戻すという操作をしています。アドホックですね。

(defun my-gofmt-hook ()
  (unwind-protect
      (progn
        (remove-hook 'before-save-hook 'my-gofmt-hook)
        (save-buffer)
        (shell-command (concat "goimports -w " (buffer-file-name)))
        (revert-buffer t t t))
    (add-hook 'before-save-hook 'my-gofmt-hook)))

実際にフックを設定しているのが下記です。セーブ後に revert-buffer でバッファを読み直さないと構文解析が狂ってしまうようだったので、after-save-hookrevert-buffer をかけています。アドホックですね。

(use-package go-mode
  :commands go-mode
  :defer t
  :config
  (add-hook 'before-save-hook 'my-gofmt-hook)
  ;; The following is required to avoid wrong analysis by LSP server.
  (add-hook 'after-save-hook
            '(lambda ()
               (revert-buffer t t t))))

*1:なお、cl-devel2 の方のエラーはこれとは無関係です

*2:この操作が終わった段階でwgetした.elファイルは消して良いと思いますが、念のため残しています

*3:一応、引用した同関数の末尾に同じエラーメッセージがありますが、パッと見た感じ引用部分が通っていれば末尾の方にいくことはなさそうに見えます

*4:慎重にやるのであれば、一時ファイルを作成してバッファの内容をコピーしてセーブ。一時ファイルに goimports をかけてから書き戻すようにするのが良さそうです。元々使おうとしていた go-mode のフックも大枠はそのような動作をしているように見えます(ちゃんと調査してませんが...)