LSP

近頃のソフトウェア開発ではLSPという技術がよく使われる。 LSPとはLanguageServer Protocolの略で、プロトコルの名称だ。 このプロトコルを使用してやりとりをできるようにした。サーバーをLSPサーバーと呼ぶ。

プログラムの開発時には関数やクラスや変数や定数の定義に移動したり、 プログラムの記述中に入力を予測し補完するといった機能が昔から実装されていた。 以前はCtagsというツールによってそれを実現していた。 またEmacsでも利用できるようにしたEtagsや、言語から独立するように実装されたGNU Globalなどがある。 Emacsは常に最強なので、Emacs自身がそのコードを読み込んでいればEmacs Lispの関数定義位置はEmacs自身が保持している。 またエディタやIDEによっては自分自身で言語の解析器を実装し、それに基いてタグジャンプや補完の機能を実現していた。 PythonではJediというPython用の言語を解析するサーバーを起動し、それらとやりとりすることで実現した。 そうして徐々に昔ながらのTAGSファイルを用いた手法を採用しないケースが増えていった。

固有の言語解析サーバーや内蔵の言語解析器は、新しい言語や、新しい言語サーバーの仕様によって再利用性が乏しかった。 Microsoftは各種言語サーバーと言語サーバーの機能を利用するテキストエディタなどのクライアントのやりとりを標準化するためにLSPを策定した。 LSPクライアントを一度実装したテキストエディタやIDEは、新しい言語や言語サーバーが登場したとしてもそれらがLSPに乗っとった言語サーバーを実装していれば、 ほとんどクライアントを変更することなくタグジャンプなどの機能を利用できるようになる。

Microsoftはそれらのプロトコルを標準化し公開すると同時に、各種プログラミング言語の言語サーバーを実装し公開した。 Visual Studio Codeではそれらの機能を利用しタグジャンプや補完といった機能を実装した。 他のテキストエディタやIDEもその流れに追従し、ベンダーやもしくはコミュニティや個人がLSP関連の機能をサポートした。 最近ではこのLSPは開発時はよく利用されるようになった。

EmacsとLSP

EmacsにはLSPクライアントとしての代表的な実装は、lsp-modeとEglotの2つがある。 lsp-modeの方がより多機能で大きく、Eglotの方が小さくシンプルでEmacsの流儀に乗っとった実装となっている。 私は小さくシンプルな方が好みなのでEglotを使用している。

Eglot + Python Language Serverでエラーが発生する

ようやく本題にきた。私はよくEmacsとEglotを用いてPythonの開発を行う。 その日もEmacsを起動し言語サーバーを起動しようとした。しかし言語サーバーは起動に失敗し異常終了した。 Emacsの言語サーバーのログを出力するためのバッファには以下のようなエラーが出力されていた。

[stderr] nil
[stderr] Process EGLOT (foo/python-mode) stderr finished
[internal] Tue May 24 23:27:25 2022:
(:message "Running language server: (/usr/local/bin/pylsp)")
[client-request] (id:1) Tue May 24 23:27:25 2022:
(:jsonrpc "2.0" :id 1 :method "initialize" :params
	  (:processId 5083 :rootPath "/opt/symdon/foo/" :rootUri "file:///opt/symdon/foo" :initializationOptions #s(hash-table size 65 test eql rehash-size 1.5 rehash-threshold 0.8125 data
																     ())
		      :capabilities
		      (:workspace
		       (:applyEdit t :executeCommand
				   (:dynamicRegistration :json-false)
				   :workspaceEdit
				   (:documentChanges t)
				   :didChangeWatchedFiles
				   (:dynamicRegistration t)
				   :symbol
				   (:dynamicRegistration :json-false)
				   :configuration t :workspaceFolders t)
		       :textDocument
		       (:synchronization
			(:dynamicRegistration :json-false :willSave t :willSaveWaitUntil t :didSave t)
			:completion
			(:dynamicRegistration :json-false :completionItem
					      (:snippetSupport :json-false :deprecatedSupport t :tagSupport
							       (:valueSet
								[1]))
					      :contextSupport t)
			:hover
			(:dynamicRegistration :json-false :contentFormat
					      ["markdown" "plaintext"])
			:signatureHelp
			(:dynamicRegistration :json-false :signatureInformation
					      (:parameterInformation
					       (:labelOffsetSupport t)
					       :activeParameterSupport t))
			:references
			(:dynamicRegistration :json-false)
			:definition
			(:dynamicRegistration :json-false :linkSupport t)
			:declaration
			(:dynamicRegistration :json-false :linkSupport t)
			:implementation
			(:dynamicRegistration :json-false :linkSupport t)
			:typeDefinition
			(:dynamicRegistration :json-false :linkSupport t)
			:documentSymbol
			(:dynamicRegistration :json-false :hierarchicalDocumentSymbolSupport t :symbolKind
					      (:valueSet
					       [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26]))
			:documentHighlight
			(:dynamicRegistration :json-false)
			:codeAction
			(:dynamicRegistration :json-false :codeActionLiteralSupport
					      (:codeActionKind
					       (:valueSet
						["quickfix" "refactor" "refactor.extract" "refactor.inline" "refactor.rewrite" "source" "source.organizeImports"]))
					      :isPreferredSupport t)
			:formatting
			(:dynamicRegistration :json-false)
			:rangeFormatting
			(:dynamicRegistration :json-false)
			:rename
			(:dynamicRegistration :json-false)
			:publishDiagnostics
			(:relatedInformation :json-false :codeDescriptionSupport :json-false :tagSupport
					     (:valueSet
					      [1 2])))
		       :experimental #s(hash-table size 65 test eql rehash-size 1.5 rehash-threshold 0.8125 data
						   ()))
		      :workspaceFolders
		      [(:uri "file:///opt/symdon/foo" :name "/opt/symdon/foo/")]))
[stderr] Traceback (most recent call last):
[stderr]   File "/usr/local/bin/pylsp", line 8, in <module>
[stderr]     sys.exit(main())
[stderr]   File "/usr/local/lib/python3.9/site-packages/pylsp/__main__.py", line 77, in main
[stderr]     start_io_lang_server(stdin, stdout, args.check_parent_process,
[stderr]   File "/usr/local/lib/python3.9/site-packages/pylsp/python_lsp.py", line 91, in start_io_lang_server
[stderr]     server.start()
[stderr]   File "/usr/local/lib/python3.9/site-packages/pylsp/python_lsp.py", line 118, in start
[stderr]     self._jsonrpc_stream_reader.listen(self._endpoint.consume)
[stderr]   File "/usr/local/lib/python3.9/site-packages/pylsp_jsonrpc/streams.py", line 40, in listen
[stderr]     message_consumer(json.loads(request_str.decode('utf-8')))
[stderr]   File "/usr/local/lib/python3.9/site-packages/pylsp_jsonrpc/endpoint.py", line 103, in consume
[stderr]     if 'jsonrpc' not in message or message['jsonrpc'] != JSONRPC_VERSION:
[stderr] TypeError: string indices must be integers
[internal] Tue May 24 23:27:26 2022:
(:message "Connection state changed" :change "exited abnormally with code 1\n")

言語サーバーの問題かLSPクライアントの問題かを切り分ける

PythonのLSP対応の言語サーバーはいくつかある。Eglotで言及されているのは以下の3つだ。

  • Python Language Server

  • Python LSP Server

  • Pyright

これらすべて試したが挙動に変化はなく、異常終了し言語サーバーは起動しなかった。 もし言語サーバーの問題であれば、全ての言語サーバーで同様に失敗するとは考えにくい。

そのためLSPクライアントの問題である可能性が高かった。 デバッグログを仕込み送信されたリクエストの中身をみると、おかしなところがあった。 上記のログの message['jsonrpc']TypeError: string indices must be integers が発生していた。 messageはおそらく辞書のようなオブジェクトを期待しているが、実際にはどうやら文字列のようだ。 そこで中身を確認すると以下のような文字列が格納されていた。

(:jsonrpc 2.0 :id 1 :method initialize :params (:processId 5083 :rootPath /opt/ng/symdon/foo/ :rootUri file:///opt/ng/symdon/foo :initializationOptions #s(hash-table size 65 test eql rehash-size 1.5 rehash-threshold 0.8125 data ()) :capabilities (:workspace (:applyEdit t :executeCommand (:dynamicRegistration :json-false) :workspaceEdit (:documentChanges t) :didChangeWatchedFiles (:dynamicRegistration t) :symbol (:dynamicRegistration :json-false) :configuration t :workspaceFolders t) :textDocument (:synchronization (:dynamicRegistration :json-false :willSave t :willSaveWaitUntil t :didSave t) :completion (:dynamicRegistration :json-false :completionItem (:snippetSupport :json-false :deprecatedSupport t :tagSupport (:valueSet [1])) :contextSupport t) :hover (:dynamicRegistration :json-false :contentFormat [markdown plaintext]) :signatureHelp (:dynamicRegistration :json-false :signatureInformation (:parameterInformation (:labelOffsetSupport t) :activeParameterSupport t)) :references (:dynamicRegistration :json-false) :definition (:dynamicRegistration :json-false :linkSupport t) :declaration (:dynamicRegistration :json-false :linkSupport t) :implementation (:dynamicRegistration :json-false :linkSupport t) :typeDefinition (:dynamicRegistration :json-false :linkSupport t) :documentSymbol (:dynamicRegistration :json-false :hierarchicalDocumentSymbolSupport t :symbolKind (:valueSet [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26])) :documentHighlight (:dynamicRegistration :json-false) :codeAction (:dynamicRegistration :json-false :codeActionLiteralSupport (:codeActionKind (:valueSet [quickfix refactor refactor.extract refactor.inline refactor.rewrite source source.organizeImports])) :isPreferredSupport t) :formatting (:dynamicRegistration :json-false) :rangeFormatting (:dynamicRegistration :json-false) :rename (:dynamicRegistration :json-false) :publishDiagnostics (:relatedInformation :json-false :codeDescriptionSupport :json-false :tagSupport (:valueSet [1 2]))) :experimental #s(hash-table size 65 test eql rehash-size 1.5 rehash-threshold 0.8125 data ())) :workspaceFolders [(:uri file:///opt/ng/symdon/foo :name /opt/ng/symdon/foo/)]))

LSPはJSON-PRCでデータの授受を行うためペイロードはJSONであるはずだが、みると何かよくわからないどこか懐しさを感じる形式だ。よくみるとLispっぽい。 クライアントはJSON-PRCのメッセージを送信する際、送信するペイロードをLispからJSONに変換するべきだが、それをせず単純に文字列としてシリアライズして送信しているようだと推測できる。 つまりEglotか、Eglotが利用しているJSON-RPC用ライブラリであるjsonrpc.elが怪しい。

見てみるとjsonrpc.elの jsonrpc--json-encode 関数がLispをJSON形式の文字列にシリアライズできていないことがわかった。 jsonrpc–json-encode関数は次のように定義されている。

(defalias 'jsonrpc--json-encode
  (if (fboundp 'json-serialize)
      (lambda (object)
        (json-serialize object
                        :false-object :json-false
                        :null-object nil))
    (require 'json)
    (defvar json-false)
    (defvar json-null)
    (declare-function json-encode "json" (object))
    (lambda (object)
      (let ((json-false :json-false)
            (json-null nil))
        (json-encode object))))
  "Encode OBJECT into a JSON string.")
/Applications/Emacs.app/Contents/Resources/lisp/jsonrpc.el.gz抜粋

しかしこれでは正しくJSON形式の文字列にシリアライズできない。 試しに以下のように実装を修正するためにスクラッチバッファに以下を記述し評価することで関数を上書きする。

(require 'jsonrpc)
(require 'json)

(defun jsonrpc--json-encode (object)
  (let ((json-false :json-false)
        (json-null nil))
    (json-encode object)))

この状態で言語サーバーを起動すると問題なく起動する。 jsonrpc.elはMELPAではなくELPAのパッケージだ。 https://elpa.gnu.org/packages/jsonrpc.html

リリースの最新版はjsonrpc-1.0.14.tar.lz(2022-Feb-10)だ。 インストールしているjsonrpc.elは同じバージョンだった。 Emacsのmaster branchでは修正されている可能性もある。

https://git.savannah.gnu.org/cgit/emacs.git/tree/lisp/jsonrpc.el#n496

特に修正されているようではなかった。

確認したところ古い状態のEmacsだとjson-serializeが適切に動作しないため、この事象が発生することがわかった。 これに遭遇したらきっとEmacsのバージョンの問題だと思われる。 素直に最新の安定板にするか、上記のモンキーパッチを当てるしかなさそうだ。

脚注