📝

C言語”で”作るLanguage Server

2023/12/23に公開

こちらはmisskey.dev ユーザー Advent Calendar 2023の23日目の記事です。
Language ServerをC言語で作ってみよう、という記事です。テキストの編集を取得して、警告を出すところまでを作ってみました。コードはすべてGitHubにあります。

Language Server

プログラミングを行うエディタにおいて、プログラミング言語ごとの処理(型チェックや補完など)を分離し、エディタによらず同一の言語機能を提供するものをLanguage Serverと呼びます。最近VimやVSCodeのLanguage Serverが急速に普及していますね。

このLanguage Serverはエディタと通信する必要がありますので、そのためのプロトコルLanguage Server Protocol(LSP)をMicrosoftが策定しています。

Language Server Protocol

Language Server Protocol(LSP)はJSON-RPCをベースにしたプロトコルです。クライアント(エディタ)が言語機能をリクエストをしてサーバが結果をレスポンスで返す、という基本的な構造になっています。詳細についてはMicrosoftのLanguage Server Protocol Specificationにまとまっているので見てみるとよいでしょう。

JSON-RPC

JSON-RPCについて簡単に補足します。JSON-RPC(JSON-Remote Procedure Call)はJSONを使ったクライアント=サーバ間の通信プロトコルです。Request、Response、Notificationの3つの種類のメッセージからなります。

Requestは以下の要素からなります。

  • method: サーバが実行するメソッドの名前
  • id: RequestとResponseに共通するユニークな数字。これでどのRequestに対するResponseか特定します
  • params: サーバがメソッドを実行するときに渡すパラメータ

Responseにはparamsの代わりにresultとしてメソッド実行の結果が含まれます。Notificationは通知のみのResponseを期待しないメッセージです。そのため、Notificationはmethodとparamsのみでidは含みません。

今回やったこと

この記事ではC言語でLanguage Serverを作ります。エディタはVimを使います。どの言語のLanguage Serverを作るのか、が問題になりますが、今回はLSPによるエディタとLSのやりとりを実装することを主眼としたいので、特定の言語の構文解析は作らず、編集されたテキストをサーバが持つ正解と比較して、間違っていれば警告を出す機能を作ります。

なぜC言語で作るのか、ですが、C言語であまりやらない領域のことなので(ネタとしてアドベントカレンダーに丁度いいかと)面白いかと思いやってみました。

LSを作る上で以下の条件を課すこととします。

  • サーバはC言語で作ること
  • C言語コンパイラに標準で含まれてそうなライブラリで作ること(POSIXに含まれるライブラリのみを使うこととする、glibcの拡張機能とかマジ勘弁)
  • クライアント側はVimScriptで作ること。クライアント側が何をやっているのかも知りたいので、既存のlspプラグインは使わないこととします

VimScriptはVim9 Scriptを使用します。

LSの仕様

今回の開発ではInitializeとShutdown、textDocument機能を作成します。

Initialize

LSPでは通信を開始するとまずInitializeを行うようになっています。Initializeでは、サーバが持つ言語機能をクライアントが把握します。LSPでは様々な機能が規定されていますが、サーバがそのすべてを実行できるとは限らないため、クライアントがサーバの機能を確認する必要があるんですね。

Initalizeフェーズではまず、クライアントがInitialize Requestを出します。サーバがInitialize Requestを受け取ると自身の持つ機能(Server Capabilities)をresultとして返信します。クライアントがResponseを受け取り、Initialized Notificationを送信してInitializeフェーズは完了します。

シーケンス図にまとめると以下です。

paramsなどの具体的な内容はLanguage Server Protocol Specificationに載っています(めっちゃ長い)。

Shutdown

クライアントは終了時にShutdown Requestを送信します。これをサーバが受け取るとShutdown Responseを返して終了を待機します。この後でExit Notificationを受け取るとサーバが終了します。Shutdownではparamsやresultは空で送信されます。

textDocument機能

textDocument処理はテキストを編集する動作についての機能群です。Initialize ResponseのServer CapabilitiesにtextDocumentSyncを含むサーバであれば使用できます。textDocumentに関連する機能はmethodがtextDocument/~という名前になっています。

今回サーバに実装するのは、

  • テキストファイルを開いたことを通知するtextDocument/didOpen Notification
  • テキストを変更したことを通知するtextDocument/didChange Notification
  • テキストファイルを閉じたことを通知するtextDocument/didClose Notification
  • テキストの検証結果(警告)を通知するtextDocument/publishDiagnostics Notification

の4つです。これらはNotificationですので、送ったら送りっぱなしで返信はありません。

ユースケース

クライアントでファイルを開いて編集し、警告が表示されたあとファイルを閉じる、という流れをシーケンス図にまとめると以下のようになります。

LSの実装

ソケット通信

上の仕様を実装するうえで、LSとエディタ間で通信を行わなければなりません。方法はいくつかありますが、今回はソケット通信を使います。C言語とVim9 Scriptはそれぞれソケット通信に対応しています。

サーバ側(C言語)

C言語でソケット通信を行うにはsys/socket.hを使います。これはPOSIXに含まれるやつです。また、アドレスにはUnixドメインソケットを使いますのでsys/un.hと、ソケット通信からの入力を監視するためにsys/select.hも使います。

sys/socket.hではsocket()でまずソケットを作成し、bind()でアドレスとソケットを紐づけます。アドレスはsockaddr_un構造体で定義しています。以下はソースの抜粋です。

#include <sys/socket.h>
#include <sys/un.h>

#define UNIXDOMAIN_PATH "/tmp/language_server.sock"

static int init_server(void) {
   int fd = 0;
   struct sockaddr_un server_addr;

   fd = socket(AF_UNIX, SOCK_STREAM, 0);
   if (fd < 0) {
   	fprintf(stderr, "init_server: socket error\n");
   	exit(-1);
   }
   
   remove(UNIXDOMAIN_PATH);
   memset(&server_addr, 0, sizeof(struct sockaddr_un));
   server_addr.sun_family = AF_UNIX;
   strcpy(server_addr.sun_path, UNIXDOMAIN_PATH);
   if (bind(fd, (struct sockaddr*)&server_addr, sizeof(struct sockaddr_un)) < 0) {
   	fprintf(stderr, "init_server: bind error\n");
   	exit(-1);
   }

       return fd;
}

socket()はファイルディスクリプタを返すので、それをbind()に渡すことでアドレスとソケットが紐づけられます。server_addr.sun_familyにAF_UNIXを指定することでUnixドメインソケットが使えるようになります。このときのファイルディスクリプタはクライアントからの接続待ち用のものです。

ソケットを作成した後は、クライアントが接続してくるまで待ちます。listen()で接続待ちを開始します。その後のaccept()でクライアントの接続を待ちます。接続されると、accept()は通信用のファイルディスクリプタを返します。accept()はクライアントからの接続があるまで終了しません。

#include <sys/socket.h>

static void connect_server(int listen_fd) {
   if (listen(listen_fd, BACKLOGSIZE) < 0) {
   	fprintf(stderr, "connect_server: listen error\n");
   	exit(-1);
   }

   return;
}

static int wait_connect(int listen_fd) {
   int client_fd = 0;
   struct sockaddr_un client_addr;
   
   memset(&client_addr, 0, sizeof(struct sockaddr_un));
   socklen_t addrlen = sizeof(struct sockaddr_un);
   printf("waiting connect...\n");
   client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addrlen);
   if (client_fd < 0) {
   	fprintf(stderr, "wait_connect: accept error\n");
   	exit(-1);
   }
   printf("connect\n");
   
   return client_fd;
}

これで接続できたので、後は送信をsend()、受信をrecv()で行います。ちなみに、接続したソケットはファイルディスクリプタで判別していますので、read()、write()でも読み書きできます。ソケットの切断もファイルのようにclose()で行えます。

今回の実装では、LSはクライアントからの接続を待ち、接続されたらファイルディスクリプタをsselect()で監視して、メッセージが書き込まれたら読み取りに行く、という作りになっています。

#include <sys/select.h>

void language_server(void) {
   int listen_fd;
   int client_fd;
   int retval = 0;

   int retselect;
   fd_set rfds;

   listen_fd = init_server();
   connect_server(listen_fd);

   client_fd = wait_connect(listen_fd);

   while(1) {
   	FD_ZERO(&rfds);
   	FD_SET(client_fd, &rfds);

   	retselect = select(client_fd+1, &rfds, NULL, NULL, NULL);
   	if (retselect < 0) {
   		fprintf(stderr, "select error\n");
   	} else if (retselect > 0) {
   		char recvbuf[RECVBUF_SIZE] = "";
   		get_message(client_fd, recvbuf, log_file);
   		retval = receive_message(client_fd, recvbuf, log_file, &current_file);
   	} else {
   	}
   }

   close(client_fd);
   close(listen_fd);

   return;
}

取得したメッセージはただの文字列ですので、JSONパーサでパースし、LSPのメソッドごとに作った構造体に入れ処理します。JSONパーサを作るのがしんどかったです。

クライアント側(Vim Script)

Vimでソケット通信を行うにはchannel機能を使います。詳細は:helpにありますし、channel-Vim日本語ドキュメントには日本語版もあります。

接続はch_open()で行います。引数にはアドレス、モード、コールバックを指定します(モード、コールバックはオプション)。アドレスは冒頭に”unix:”をつけるとUnixドメインソケットのアドレスとして認識されます。モードは”json”, “js”, “nl”, “raw”, “lsp”のうちどれかで、今回は”lsp”を指定します。”lsp”モードではJSON-RPCのエンコード/デコードを自動でやってくれます。便利ですね。
 コールバックには非同期で受け取ったメッセージを処理するコールバック関数の名前を入れます。今回の場合、クライアントが非同期として受け取るのはtextDocument/publishDiagnostics Notificationだけですので、それを処理する関数を指定しています。

function! s:LS_ClientInit()
	let s:server_path = 'unix:/tmp/language_server.sock'
	let s:channel = ch_open(s:server_path, #{mode: 'lsp', callback: 'GetDiagnostics'})
endfunction

メッセージの同期送信はch_evalexpr()を使います。

let reqstatus = ch_evalexpr(s:channel, req, #{timeout: 100})

s:channelはch_open()で開いたチャンネル、reqは送信するメッセージです。reqにはVimのDict型を入れると勝手にJSON-RPCにしてくれます。同期送信の場合、返り値に返信メッセージ(JSON-RPCがデコードされてDict型になったもの)が入ります。オプションでtimeoutを指定しておくと、規定の時間でタイムアウトしてくれます。

非同期送信の場合、ch_sendexpr()を使います。

sendexpr(s:channel, req)

これでC言語サーバとVim間でソケット通信ができるようになりましたので、あとはLSPに従ったメッセージを作って通信を行えばよいです。

クライアント側での試行錯誤

サーバ側は泥臭くC言語を書いただけなのでこれ以上の話題はありません。C言語で今風のプロトコルを扱うのはしんどいなと思いました。
 しかし、Vim Scriptでクライアントを作るときにちょっと試行錯誤したので、それについて書きます。

メッセージ送信のタイミング

LSPにはクライアントの動作で発火するメッセージがあります。textDocument/didOpen, textDocument/didChange, textDocument/didCloseがそれです。

Vimにおいて、ユーザの動作を受けて自作の関数を動かしたいときはautocmdを使うと良いです。
今回の実装では以下のようにしています。

autocmd! BufReadPost *.txt call s:LS_textOpen()
autocmd! TextChangedI *.txt call s:LS_textChange()
autocmd! ExitPre *.txt call s:LS_textClose()

“BufReadPost”などが発火するタイミング(イベント)です。どういうイベントが指定できるかはautocmd-Vim日本語ドキュメントのイベントの項に一覧があります。
"*.txt"のところには対象のファイルを入れます。"*"だけにするとすべての種類のファイルが対象になります。

テキストの変更の取得

Vim Scriptではこれが一番難しかったです。上手い方法が見つからずだいぶ詰まったのですが、最終的に:changesを利用することにしました。
 Vimは:changesでファイルの変更履歴を取得できます。これを利用して、テキストが変更されたときに:changesの変更履歴の一番新しいものを取り出し、整形して使うというふうにしました。

function! s:GetChange()
	let change_result = execute('changes')
	let change_list = split(change_result, '\n')
	let line = split(change_list[len(change_list) - 2])

	let result = " "
	for i in range(3, len(line) - 1)
		let result = result . " " . line[i]
	endfor
	
	return result
endfunction

かなりの力技ですし、:changesの変更は行ごとなので、行のどこでどう変更されたかは取得できません。なので仕方なくtextDocument/didChange通知では変更のあった行を丸ごと送っています。もし他に良い方法があればご教示いただきたいです。

警告のポップアップ

サーバはtextDocument/didChange通知を受けると、テキストの変更を比較して警告を返します。その警告をVimで表示するために、popup機能を使っています。

popup_atcursor(message, #{pos: 'topleft'})

popupについてもpopup-Vim日本語ドキュメントを読んでもらうのが早いです(ドキュメントがすげぇ充実してんだ)。popup_atcursor()ではカーソルの位置にポップアップが出て、カーソルが移動するとポップアップが閉じます。

結果

こんな感じで警告を出してくれます。この時の正解は”print HelloWorld”で、wが小文字なので警告のポップアップが出ています。

まとめ

C言語でLanguage Server、VimをクライアントとしてLSPのプロトコルを実装してみました。予想できたことですが、C言語でJSONを基底としたプロトコルを実装するのはかなり大変です。JSONのパースも大変ですし、プロトコルの仕様とにらめっこしながらそれぞれのパラメータを構造体に落とし込むのも大変です。しかし、C言語のベーシックな表現でもLSを作ることができるとわかりました。
 プログラミング言語やエディタの機能はどんどん肥大化し、開発環境導入のハードルが高くなっています。さあコードを書こうと思ったとき、1日かけて必要なツールを揃えて、一行も書かずに満足するくらいなら、最初から入ってるC言語コンパイラとVimを使って、自分で作ってみるのもアリじゃないでしょうか。

参考文献

LSPやソケット通信について全く知らなかったので以下のサイトに頼りまくりました。

LSPについて

C言語のソケット通信について

Vim Scriptのソケット通信について

Discussion