LoginSignup
3
4

Monaco Editor (React) + Clangd でC言語のコード補完付きエディタを作る

Last updated at Posted at 2023-12-27

概要

Monaco EditorとClangdを使ってC言語のコード補完が動くところまで辿り着いたので記録しておきます。需要があるか分かりませんが日本語記事がほぼ無い状態だったので誰かの参考になればと思います。

スクリーンショット 2023-12-27 133317.png

環境と技術

  • 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(その他諸々)を利用することになります。
  • 通常言語サーバーと接続するにはipcstdiosocket等で行うことになります。あくまで通信フォーマット(LSP)は共通ですが接続方法は対応に差異があります。
  • Clangdは現時点でstdioしか対応していません。
  • 一方でmonaco-languageclientはstdioのサンプルコードがなくsocket等しかありません。

構成

Clangdはstdio、monacoはsocketですからsocket通信をstdioに変換する処理が必要になります。

雑把な図で恐縮ですが以下のような通信構成になります。

monaco-clangd.png

ソースコード

ソースコードは試行錯誤の塊のため不要なコードが多く含まれる可能性があります。必ずしもこの通りで動くとは限らず、またもっといいやり方があるかもしれません。

Clangd(サーバー側)

main.js
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エディタ本体部分です。

Editor.tsx
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で作られたモデルは正常に動きませんでした。
  • workspaceUriRegisteredMemoryFile等で指定する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へ追記が必要です。詳細はこちら

次にクライアント側です。

worker/monaco.ts
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の変換が必要のようです。
  • この技術の組み合わせでは日本語の記事がなかなか見つからず英語サイトも数少ない分野でしたから相当苦労しました。

もっと簡単にできるよ、最新だとこうなったよ、等あれば教えてください。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4