はじめに こんにちは、Future でアルバイトをしている空閑と申します。本記事ではタイトルの通り、Pyright を LSP (Language Server Protocol) サーバとした自作クライアントを実装しますが、その前に経緯について説明します。本節では実装については触れません。
アルバイトの前は、Future のインターン Engineer Camp で Python のソースコード解析に取り組んでいました。そのときの様子は、Engineer Camp2021: Python の AST モジュールを使ってクラス構造を可視化する で触れています。当時は Python の AST モジュールを活用する方針で、それ以外は自前で解析を行っていました。アルバイトでも引き続き解析に取り組んでいますが、次第に型推論などの技術が必要になってきており、全てを自前で実装することは困難な状況です。そこで現在は、既存のツールを拡張する方針を取っています。
ツールの候補としては、Mypy および Pyright が挙がりましたが、検討の結果(Mypy と Pyright の解析比較 )Pyright を拡張することにしています。Pyright は Pylance 上での実行を前提としているため、入力補完などで使う、型チェックにとどまらない情報を取得できることが理由の1つです。また、Pyright には LSP での実装が存在するため、これを利用することで、Pyright 本体の実装に手を加える必要がなく、システムを疎結合に保てます。
問題は、LSP クライアントをエディタ(主に VSCode)以外で実装するサンプルがほとんどないことです。解析ツールはコマンドラインで動作するようにしたいため、エディタ依存の機能は使えません。LSP の仕様 は公開されているものの、詳細な手順については記載がありません。特に Pyright における初期化の手順は、実際の実装を追う必要があり苦戦しました。次節以降では、初期化の手順を含めた自作 LSP クライアントの実装方法を紹介します。
自作 LSP クライアントの作成 仕様
解析対象:Python
LSP サーバ:Pyright
LSP クライアント:CLI(Node.js, TypeScript)
目標
サーバ・クライアント間のメッセージ送受信
メッセージによる簡単な解析結果の取得
最低限実装が必要なメッセージ
Initialize Request :サーバの初期化を要求
Initialized Notification :クライアント側の初期化が完了したことを通知
DidChangeWorkspaceFolders Notification :ワークスペースフォルダの変更を通知
詳しくは Pyright を LSP サーバとした自作 LSP クライアント(調査編) を参照してください。
実装 サーバ起動・メッセージ受信 LSP サーバのパスを指定し、子プロセスで起動します。Pyright のリポジトリをクローンした場合、サーバのパスは pyright/packages/pyright/langserver.index.js
です。Pyright は実行時引数で通信方法を指定できます。--node-ipc
、--stdio
、--socket={number}
から選ぶことができますが、今回は --node-ipc
を採用します。
接続を確立するために vscode-jsonrpc
の createMessageConnection
を使います。vscode-jsonrpc
はメッセージプロトコルのライブラリで、今後もたびたび出てきます。setup(connection)
ではメッセージハンドラを定義します。最初なのでとりあえず、notification
と error
メッセージが来た時に内容を出力することにします。
client.ts import * as child_process from 'child_process' ;import * as path from 'path' ;import * as rpc from 'vscode-jsonrpc/node' ;function setup (connection: rpc.MessageConnection ) { connection.onUnhandledNotification ((e ) => { console .log ('notification' , e); }); connection.onError ((e ) => { console .log ('error' , e); }); } export function main ( ) { const modulePath = path.resolve ('/path/to/langserver.index.js' ); const childProcess = child_process.fork (modulePath, ['--node-ipc' ]); const connection = rpc.createMessageConnection ( new rpc.IPCMessageReader (childProcess), new rpc.IPCMessageWriter (childProcess) ); setup (connection); connection.listen (); } main ();
以上のコードを実際に実行すると、サーバが起動したことを通知するメッセージを window/logMessage
メソッドで受信できます。
{ jsonrpc : '2.0' , method : 'window/logMessage' , params : {type : 3 , message : 'Pyright language server 1.1.182 starting' } }
Initialize Request 今はまだ起動してメッセージを受信するだけのプログラムなので、今度はメッセージを送信してみます。仕様では最初に Initialize Request を送ることになっているので、これを実装します。サーバにリクエストを送る場合には connection.sendRequest(type, params)
を使います。type
はメソッドの種類、params
はメソッド固有のパラメータになります。これらの型定義は vscode-languageserver-protocol
にあるので、適当に参照します。
InitializeParams
にはいくつかのプロパティがありますが、最低限実装すべきは次の 4 つです。
processId
(サーバの親プロセスの ID)
rootUri
(解析したいワークスペースのルート URI、適当でよいです)
capabilities
(クライアントが実装している機能、無いので空)
workspaceFolders
(解析したいワークスペースのフォルダ、とりあえず空)
client.ts import ...import * as url from 'url' ;import * as lsp from 'vscode-languageserver-protocol' ;class InitializeParams implements lsp.InitializeParams { processId : number ; rootUri : string ; capabilities : lsp.ClientCapabilities ; workspaceFolders : lsp.WorkspaceFolder [] | null ; constructor ( ) { this .processId = process.pid ; this .rootUri = url.pathToFileURL (path.resolve ('./' )).toString (); this .capabilities = {}; this .workspaceFolders = null ; } } function setup (connection: rpc.MessageConnection ) { ... } export async function main ( ) { ... connection.listen (); const initializeResult = await connection.sendRequest (lsp.InitializeRequest .type , new InitializeParams ()); console .log ('initialize' , initializeResult); } main ();
Initialize Request が正しく送れていると、Initialize Result が返ってきます。こちらにも capabilities
が含まれていますが、これはサーバ側で実装されている機能の一覧になります。
{ capabilities : { textDocumentSync : 2 , definitionProvider : {…}, declarationProvider : {…}, referencesProvider : {…}, … } }
Initialized Notification 次は Initialize Result を受けて、クライアント側の初期化が終わったことを通知するために Initialized Notification を送信します。サーバに通知を送る場合には connection.sendNotification(type, params)
を使います。InitializedParams
は空のオブジェクトなので実装はしないで {}
を直接入力することにします。
client.ts import ...class InitializeParams implements lsp.InitializeParams { ... } function setup (connection: rpc.MessageConnection ) { ... } export async function main ( ) { ... const initializeResult = await connection.sendRequest (lsp.InitializeRequest .type , new InitializeParams ()); console .log ('initialize' , initializeResult); connection.sendNotification (lsp.InitializedNotification .type , {}); } main ();
Initialized Notification を送ると、client/registerCapability
メソッドのメッセージが送られてきます。これはサーバがクライアントに対して機能追加を要求するメッセージになります。今はまだ、このメソッドに対するハンドラを定義していないので、ハンドラを定義して受信できるようにします。
client.ts import ...class InitializeParams implements lsp.InitializeParams { ... } function setup (connection: rpc.MessageConnection ) { ... connection.onRequest (lsp.RegistrationRequest .type , (e ) => { console .log ('client/registerCapability' , e); }); } export async function main ( ) { ... } main ();
すると、以下のような内容のメッセージを受信したことがわかります。次はこのメソッドを実装します。
{ id : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' , method : 'workspace/didChangeWorkspaceFolders' , registerOptions : {} }
DidChangeWorkspaceFolders Notification ワークスペースフォルダを変更するメソッドを実装します。これを実行することで、解析対象のフォルダを変更できます。InitializeParams
同様に DidChangeWorkspaceFoldersParams
を実装します。プロパティは多いですが、単にワークスペースとして認識するフォルダの追加と削除を行っているだけです。また、DidChangeWorkspaceFolders Notification はデフォルトではサーバ側から認識されないため、InitializeParams.capabilities
にワークスペース機能があることを記載します。詳細は、Pyright を LSP サーバとした自作 LSP クライアント(調査編) で解説しています。
client.ts import ...class InitializeParams implements lsp.InitializeParams { ... constructor ( ) { this .capabilities = { workspace : { workspaceFolders : true } }; } } class WorkspaceFoldersChangeEvent implements lsp.WorkspaceFoldersChangeEvent { constructor (public added: lsp.WorkspaceFolder[], public removed: lsp.WorkspaceFolder[] ) {} } class WorkspaceFolder implements lsp.WorkspaceFolder { constructor (public uri: string , public name: string ) {} } class DidChangeWorkspaceFoldersParams implements lsp.DidChangeWorkspaceFoldersParams { event : WorkspaceFoldersChangeEvent ; constructor (added: lsp.WorkspaceFolder[], removed: lsp.WorkspaceFolder[] ) { this .event = new WorkspaceFoldersChangeEvent (added, removed); } } function setup (connection: rpc.MessageConnection ) { ... connection.onRequest (lsp.RegistrationRequest .type , (e ) => { console .log ('client/registerCapability' , e); connection.sendNotification ( lsp.DidChangeWorkspaceFoldersNotification .type , new DidChangeWorkspaceFoldersParams ( [new WorkspaceFolder (url.pathToFileURL (path.resolve ('./' )).toString (), 'dev' )], [] ) ); }); } export async function main ( ) { ... } main ();
実装が上手くいっていれば、DidChangeWorkspaceFolders Notification を送信したタイミングで、window/logMessage
メソッドのメッセージが大量に受信できると思います。主に解析対象のファイルや、仮想環境の情報を通知してくれています。
Assuming Python platform Windows Searching for source files Auto-excluding \path\to\.venv Auto-excluding \path\to\myvenv Found {number} source files
解析メッセージ 以上で、解析に必要な初期化メッセージをすべて実装したことになり、ここから先は自由にメッセージを送信できます。今回は最近のエディタでよく見かける、Hover Request を送信してみます。FastAPI を使用した以下のファイルを対象にします。
main.py from fastapi import FastAPIapp = FastAPI() @app.get("/hello" ) async def hello (): return {"message" : "hello world!" }
今までと同様に、HoverParams
を実装し適切な引数でメッセージを送信します。今回はテストなので、引数は手動で設定します。Position.create(2, 6)
は 0-based で行数と文字数を指定しており、3 行目の 7 文字目、FastAPI()
にカーソル位置があることを示しています。
client.ts class HoverParams implements lsp.HoverParams { constructor (public textDocument: lsp.TextDocumentIdentifier, public position: lsp.Position ) {} } connection.sendRequest ( lsp.HoverRequest .type , new HoverParams ( lsp.TextDocumentIdentifier .create (url.pathToFileURL (path.resolve ('./main.py' )).toString ()), lsp.Position .create (2 , 6 ) ) ).then ((e ) => { console .log (e); });
実行すると以下のようなメッセージが受信でき、カーソルを合わせた時のような情報が得られました。
{ contents : { kind : 'plaintext' value : '(class) FastAPI(*, debug: bool = False, routes: List[BaseRoute] | None = None, title: str = "FastAPI", description: str = "", version: str = "0.1.0", openapi_url: str | None = "/openapi.json", openapi_tags: List[Dict[str, Any]] | None = None, servers: List[Dict[str, str | Any]] | None = None, dependencies: Sequence[Depends] | None = None, default_response_class: Type[Response] = Default(JSONResponse), docs_url: str | None = "/docs", redoc_url: str | None = "/redoc", swagger_ui_oauth2_redirect_u…one = None, on_startup: Sequence[() -> Any] | None = None, on_shutdown: Sequence[() -> Any] | None = None, terms_of_service: str | None = None, contact: Dict[str, str | Any] | None = None, license_info: Dict[str, str | Any] | None = None, openapi_prefix: str = "", root_path: str = "", root_path_in_servers: bool = True, responses: Dict[int | str, Dict[str, Any]] | None = None, callbacks: List[BaseRoute] | None = None, deprecated: bool | None = None, include_in_schema: bool = True, **extra: Any)' } range : { end : {line : 2 , character : 13 } start : {line : 2 , character : 6 } } }
全体の実装
長いので折り畳み
client.ts import * as child_process from 'child_process' ;import * as path from 'path' ;import * as url from 'url' ;import * as rpc from 'vscode-jsonrpc/node' ;import * as lsp from 'vscode-languageserver-protocol' ;class InitializeParams implements lsp.InitializeParams { processId : number; rootUri : string; capabilities : lsp.ClientCapabilities ; workspaceFolders : lsp.WorkspaceFolder [] | null ; constructor ( ) { this .processId = process.pid ; this .rootUri = url.pathToFileURL (path.resolve ('./' )).toString (); this .capabilities = { workspace : { workspaceFolders : true } }; this .workspaceFolders = null ; } } class WorkspaceFoldersChangeEvent implements lsp.WorkspaceFoldersChangeEvent { constructor (public added: lsp.WorkspaceFolder[], public removed: lsp.WorkspaceFolder[] ) {} } class WorkspaceFolder implements lsp.WorkspaceFolder { constructor (public uri: string, public name: string ) {} } class DidChangeWorkspaceFoldersParams implements lsp.DidChangeWorkspaceFoldersParams { event : WorkspaceFoldersChangeEvent ; constructor (added: lsp.WorkspaceFolder[], removed: lsp.WorkspaceFolder[] ) { this .event = new WorkspaceFoldersChangeEvent (added, removed); } } class HoverParams implements lsp.HoverParams { constructor (public textDocument: lsp.TextDocumentIdentifier, public position: lsp.Position ) {} } function setup (connection: rpc.MessageConnection ) { connection.onUnhandledNotification ((e ) => { console .log ('notification' , e); }); connection.onError ((e ) => { console .log ('error' , e); }); connection.onNotification (lsp.LogMessageNotification .type , (e ) => { console .info (e.message ); }); connection.onRequest (lsp.RegistrationRequest .type , (e ) => { console .log ('client/registerCapability' , e); connection.sendNotification ( lsp.DidChangeWorkspaceFoldersNotification .type , new DidChangeWorkspaceFoldersParams ( [new WorkspaceFolder (url.pathToFileURL (path.resolve ('./' )).toString (), 'dev' )], [] ) ); connection .sendRequest ( lsp.HoverRequest .type , new HoverParams ( lsp.TextDocumentIdentifier .create (url.pathToFileURL (path.resolve ('./main.py' )).toString ()), lsp.Position .create (2 , 6 ) ) ) .then ((e ) => { console .log (e); }); }); } export async function main ( ) { const modulePath = path.resolve ('./packages/cli-pyright/langserver.index.js' ); const childProcess = child_process.fork (modulePath, ['--node-ipc' ]); const connection = rpc.createMessageConnection ( new rpc.IPCMessageReader (childProcess), new rpc.IPCMessageWriter (childProcess) ); setup (connection); connection.listen (); const initializeResult = await connection.sendRequest (lsp.InitializeRequest .type , new InitializeParams ()); console .log ('initialize' , initializeResult); connection.sendNotification (lsp.InitializedNotification .type , {}); } main ();
感想 今回は、自作の LSP クライアントを実装しました。機能としては不十分ですが、遊ぶ分には楽しめると思います。本来の目的は既存のメッセージを組み合わせての解析なのですが、実際のところかなり面倒です…。LSP が解析目的のプロトコルではないので当然ですが。現在は Pyright 内部をいじることも検討しているので、LSP サーバ側の実装についても今後機会があれば紹介したいと思います。
参考