概要
Monaco EditorとClangdを使ってC言語のコード補完が動くところまで辿り着いたので記録しておきます。需要があるか分かりませんが日本語記事がほぼ無い状態だったので誰かの参考になればと思います。
環境と技術
- Windows11
- React
- Tauri
- Monaco Editor
- Clangd
Tauri(Monaco)とClangdは性質上別プロセスでの実行になりますが、Tauri単体の起動でこの両方が連携するところまでは到達できていません。あくまでデスクトップ上で連携できたんだという紹介ができればと思います。
技術紹介
Monaco Editor
Monaco EditorとはMicrosoftを中心に開発されているオープンソースのコードエディタです。VSCodeなどで利用されているエディタになります。
Clangd
ClangdとはC言語の言語サーバで、クライアントと連携することでコード補完を可能とします。クライアントとは主にエディタのことを指します。
LSP (Language Server Protocol)
上記のClangdなどの言語サーバと、Monaco Editorなどのクライアント間でやり取りされるプロトコルです。Microsoftが2016年に提唱しました。
以下の記事に詳しく書かれています。
https://qiita.com/atsushieno/items/ce31df9bd88e98eec5c4
Tauri
ついでにTauriの紹介も。
クロスプラットフォームを可能とするデスクトップアプリの開発フレームワークです。いわゆるElectronに似ていますがChromium等が同封されていないため軽量です。2022年にバージョン1.0がリリースされた最近のフレームワークになります。
実装
- Monaco Editorの言語クライアントはmonaco-languageclient(その他諸々)を利用することになります。
- 通常言語サーバーと接続するには
ipc
、stdio
、socket
等で行うことになります。あくまで通信フォーマット(LSP)は共通ですが接続方法は対応に差異があります。 - Clangdは現時点で
stdio
しか対応していません。 - 一方でmonaco-languageclientは
stdio
のサンプルコードがなくsocket
等しかありません。
構成
Clangdはstdio
、monacoはsocket
ですからsocket通信をstdioに変換する処理が必要になります。
雑把な図で恐縮ですが以下のような通信構成になります。
ソースコード
ソースコードは試行錯誤の塊のため不要なコードが多く含まれる可能性があります。必ずしもこの通りで動くとは限らず、またもっといいやり方があるかもしれません。
Clangd(サーバー側)
import { WebSocketServer } from "ws";
import * as rpc from "vscode-ws-jsonrpc";
import * as lsp from "vscode-languageserver";
import * as server from "vscode-ws-jsonrpc/server";
import { Message } from "vscode-languageserver";
const port = 3030;
const wss = new WebSocketServer({
perMessageDeflate: false,
port: port,
});
wss.on("connection", (ws, req) => {
console.log("connected");
ws.on("close", () => {
console.log("disconnected");
});
ws.on("error", (err) => {
console.error(err);
});
launch(rpc.toSocket(ws));
});
/**
* Laucnh c language server
* @param {rpc.IWebSocket} socket
*/
function launch(socket) {
const reader = new rpc.WebSocketMessageReader(socket);
const writer = new rpc.WebSocketMessageWriter(socket);
const socketConnection = server.createConnection(reader, writer, () =>
socket.dispose()
);
const serverConnection = server.createServerProcess("c", "./clangd_17.0.3/bin/clangd.exe");
if (serverConnection) {
server.forward(socketConnection, serverConnection, (message) => {
if (Message.isRequest(message)) {
if (message.method === lsp.InitializeRequest.type.method) {
/** @type lsp.InitializeParams */
const initializeParams = message.params;
initializeParams.processId = process.pid;
}
}
return message;
});
} else {
console.error("Failed to create server process.");
}
}
console.log(`launched server on ${port}`);
上記のコードをnode main.js
で起動するとポート3030番でsocket通信を待機します。
接続されるとclangdが起動しやり取りを開始します。
Monaco Editor(クライアント側)
monacoエディタ本体部分です。
import { useContext, useEffect, useRef, useState } from "react";
import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution';
import "@codingame/monaco-vscode-language-pack-ja";
import * as monaco from "monaco-editor";
import { initServices } from "monaco-languageclient";
import { createWebSocketAndStartClient } from "../worker/monaco";
import { createConfiguredEditor, createModelReference } from "vscode/monaco";
import { RegisteredFileSystemProvider, RegisteredMemoryFile, registerFileSystemOverlay } from "@codingame/monaco-vscode-files-service-override";
type EditorProps = {
styles?: React.CSSProperties;
}
const languageId = "c";
const Editor: React.FC<EditorProps> = (props) => {
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor | null>(null);
const monacoEl = useRef(null);
const [init, setInit] = useState<boolean>(false);
let ws: WebSocket;
useEffect(() => {
(async () => {
if (monacoEl && !init) {
await initServices({
debugLogging: true,
workspaceConfig: {
workspaceProvider: {
trusted: true,
workspace: {
workspaceUri: monaco.Uri.file('/workspace')
},
async open() {
return false;
}
}
}
});
monaco.languages.register({
id: languageId,
extensions: [".c", ".cpp"],
aliases: ["C", "c"]
});
const fileSystemProvider = new RegisteredFileSystemProvider(false);
fileSystemProvider.registerFile(
new RegisteredMemoryFile(monaco.Uri.file("/workspace/main.c"), ""),
);
registerFileSystemOverlay(1, fileSystemProvider);
const model = await createModelReference(monaco.Uri.file("/workspace/main.c"));
model.object.setLanguageId(languageId);
setEditor((editor) => {
if (editor) return editor;
return createConfiguredEditor(monacoEl.current!, {
language: languageId,
model: model.object.textEditorModel,
automaticLayout: true,
});
});
ws = createWebSocketAndStartClient("ws://localhost:3030");
setInit(true);
}
})();
return () => {
editor?.dispose();
ws?.close();
}
}, []);
return <div ref={monacoEl} style={props.styles} />
}
export default Editor;
ws = createWebSocketAndStartClient("ws://localhost:3030");
上記で言語クライアントを構築し言語サーバへ接続します。createWebSocketAndStartClient
については後述します。
いくつか注意点です。
-
monaco.editor.create
で作られたエディタは正常に動きませんでした。 - 同様に
monaco.editor.createModel
で作られたモデルは正常に動きませんでした。 -
workspaceUri
、RegisteredMemoryFile
等で指定するURIは仮想です。実在する必要はありません。
追記:定義や宣言箇所を表示させるときなどに指定した実在のファイルパスを参照するようです。- 拡張子はきちんと実在する
.c
などを指定しないとclangd側で正しく動作しません。
- 拡張子はきちんと実在する
-
import "monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution";
をimportしないと自動括弧閉じなどデフォルトの補完が動きません。 -
monaco-editor/loader
が使えませんから、日本語化したいときはimport "@codingame/monaco-vscode-language-pack-ja";
を利用します。 -
monaco-languageclient
を導入するためmonaco-editorなどのバージョンが固定されます。package.jsonへ追記が必要です。詳細はこちら。
次にクライアント側です。
import { MonacoLanguageClient } from "monaco-languageclient";
import { CloseAction, ErrorAction, MessageTransports } from "vscode-languageclient"
import { toSocket, WebSocketMessageReader, WebSocketMessageWriter } from "vscode-ws-jsonrpc";
import * as vscode from "vscode";
const languageId = "c";
export const createLanguageClient = (transports: MessageTransports): MonacoLanguageClient => {
return new MonacoLanguageClient({
name: 'C Language Client',
clientOptions: {
// use a language id as a document selector
documentSelector: [languageId],
// disable the default error handler
errorHandler: {
error: () => ({ action: ErrorAction.Continue }),
closed: () => ({ action: CloseAction.DoNotRestart })
},
synchronize: {
fileEvents: [vscode.workspace.createFileSystemWatcher('**')]
}
},
// create a language client connection from the JSON RPC connection on demand
connectionProvider: {
get: () => {
return Promise.resolve(transports);
}
}
});
};
export const createWebSocketAndStartClient = (url: string): WebSocket => {
const webSocket = new WebSocket(url);
webSocket.onopen = async () => {
const socket = toSocket(webSocket);
const reader = new WebSocketMessageReader(socket);
const writer = new WebSocketMessageWriter(socket);
const languageClient = createLanguageClient({
reader,
writer
});
languageClient.start();
reader.onClose(() => languageClient.stop());
};
return webSocket;
};
いくつか注意点です。
-
new MonacoLanguageClient
内で設定されているsynchronize
はサンプルコード(monaco-languageclient/packages/examples/src/common/client-commons.ts)にはありませんでした。これが無いと動きませんでした。 -
new MonacoLanguageClient
で指定しているlanguageId
はEditor.tsxで指定したlanguageId
と一致している必要があります。
課題
- 複数エディタに対応できるか
- 複数言語に対応できるか
- (Tauriを使用しているので)単一アプリで解決できるか
- vscodeの内部APIを利用しているような…今後破壊的変更が起きた時対応できるか
所感
- monacoで利用するモジュールが多岐にわたり、vscodeがつくモジュールをふんだんに利用します。従って複雑かつ直感的ではなく仕様を理解するには一苦労しました。(まだ理解できてないですが…)
- ace editorではよりスマートでしたがサーバ側でURIの変換が必要のようです。
- この技術の組み合わせでは日本語の記事がなかなか見つからず英語サイトも数少ない分野でしたから相当苦労しました。
もっと簡単にできるよ、最新だとこうなったよ、等あれば教えてください。