monaco editor に自作言語拡張(インデント、補完、syntax エラー)を登録する

Page content

次の URL で提供している LuneScript playground 環境のエディタを、 シンプルな textarea からモダンな monaco editor に変更しました。

<https://ifritjp.github.io/LuneScript-webFront/lnsc_web_frontend/for_wasm/>

今回のネタは、monaco editor に独自言語の次の機能を追加する方法についてです。

  • インデント調整
  • コード補完
  • syntax エラー表示

monaco editor

monaco editor は、 vscode のエディタ・コアです。

<https://microsoft.github.io/monaco-editor/>

web 上で動作する高機能エディタには、 monaco editor とは別に Ace もありますが、 今回は monaco editor の方を採用しました。

その理由は、 近い将来的に vscode 用の LuneScript extension を作成するときに、 monaco editor を知っていた方が役立つこともあるんじゃないか?と思ったためです。

実際に役立つかどうかは不明ですが。。

独自言語の登録

monaco editor に独自言語の処理を登録するには、 先ず次のように言語 ID を monaco editor に登録する必要があります。

    monaco.languages.register({
        id: "LuneScript",
    });

この言語 ID に紐付けて、補完処理などの機能を登録します。

機能登録後、 editor のインスタンスを生成する際、 登録した言語 ID を指定します。

  let monacoEditor = monaco.editor.create( element, {
      language: "LuneScript",
  });

これによって生成した editor は、指定の言語を扱うようになります。

インデント

monaco の標準的なインデント制御機能は頭が良いので、 多くの場合 monaco 内のパラメータの設定程度で十分うまく動作すると思います。

ただ、今回は LuneScript 向けにインデント制御を別途作ってあったので、 それを利用します。

そのため、 monaco の組込みインデント機能を無効化するため、 autoIndent に "none" を設定します。

  let monacoEditor = monaco.editor.create( element, {
      language: "LuneScript",
      // 組込みのインデント機能を off
      autoIndent: "none",
  });

次に特定のキー入力時にインデントを調整するようにバインドします。

ここでは、 次のキー入力時にインデント調整するように設定しています。

  • TAB
  • Enter
  • C-j
  • {
  • }
        this.monacoEditor.onKeyUp( async (e) => {
            if (e.keyCode === monaco.KeyCode.Tab) {
                e.preventDefault();
                e.stopPropagation();
                // タブキーが押されたときの処理
                this.updateIndent( monacoEditor.getSelection() );
            } else if ( e.keyCode === monaco.KeyCode.Enter ||
                        e.keyCode == monaco.KeyCode.KeyJ && e.ctrlKey ||
                        e.keyCode === monaco.KeyCode.BracketLeft ||
                        e.keyCode === monaco.KeyCode.BracketRight )
            {
                // Enter, C-j, {, }
                let selection = monacoEditor.getSelection();
                this.updateIndent( selection );
            }
        });

上記コードの this.updateIndent() は、自前で作成したインデント調整処理です。

monaco editor の組込みインデント調整機能を利用する場合、上記処理は不要です。

コード補完

コード補完は、次のように registerCompletionItemProvider() を使って コールバック情報を登録します。

    monaco.languages.registerCompletionItemProvider( "LuneScript", {
        // "." で補完開始
        triggerCharacters: ["."],
        // 補完関数
        provideCompletionItems: async function( model, position, context ) {
        }
    }

このコールバック情報は provideCompletionItems を含みます。 この provideCompletionItems は、 エディタ上で英数字を入力している際に、呼び出されているようです。

なお、 triggerCharacters で指定している文字を入力した際も、 provideCompletionItems がコールされます。

また、 provideCompletionItems に登録しているコールバック関数の引数 model, position, context は、それぞれ次を示します。

  • model

    • 編集中の editor のデータを保持する model。 editor.getModel() が返す値と同じ。
  • position

    • 編集中の位置
  • context

    • 補完のトリガに関する情報
    • 例えば context.triggerKind は、補完のトリガの種別を示します。

このコールバック関数は、 次のような Object を返すように作成します。

{ incomplete: true, suggestions:[] }

ここで、 incomplete は補完処理中かどうを示す値で、 この値が true の結果を受けた monaco editor は後で再度コールバック関数を呼びます。

suggestions は、補完候補の配列を示します。

個々の補完候補は以下のような情報を保持します。

  {
      label: "hoge",
      kind: monaco.languages.CompletionItemKind.Snippet,
      insertText: "hoge",
      range: targetRange,
      //command: { id: 'editor.action.insertLineAfter' }
  }

それぞれの項目は以下の通りです。

  • label

    • 補完候補をリスト表示する際に使われる文字列
  • kind

    • 補完候補の種別
  • insertText

    • 実際に補完文字列として展開される値
  • range

    • insertText を置き換える場所
  • command

    • 置き換え後に実行する command
    • 上記のサンプルではコメントアウトしているが、 補完時にコマンド実行が必要ならここで登録できる

syntax エラー

syntax エラーを表示するには、monaco の Marker 機能を利用します。

補完には補完機能を実行するトリガが登録できますが、 syntax をチェックするトリガは、特に規定されていないようです。

ただ、 onDidChangeModelContent() を使うことで、 エディタの内容が編集された場合のコールバックを登録できるので、 このコールバックをトリガに利用して syntax チェックします。

とはいえ、 syntax チェックはそこそこ重い処理であるのと、 1 文字編集するごとにチェックしてもすぐに次の文字が入力されて、 直前の syntax チェックの多くは無駄になるため、 onDidChangeModelContent() では変更があったことだけ記録し、 周期的タイマーで変更の有無をチェックし、変更があった場合に syntax チェックを掛けるようにします。 こうすることで、リアルタイム性は少し悪くなりますが、 無駄なチェック処理に CPU パワーを取られることを避けられます。

syntax チェックは、 当然独自処理でそれぞれの環境に合せて実施する必要があるため、ここでは省略します。

Marker の登録

自前の syntax チェックによってエラー箇所の情報を取得した後は、 その情報を Marker に登録します。

それが、 setModelMarkers() です。

  monaco.editor.setModelMarkers(
      this.monacoEditor.getModel(), "lnsc-diag", markerList );

上記の第1引数は Marker を登録する Model。 第2引数は Marker の識別名。 第3引数は Marker の情報リストです。

第3引数は Marker の情報リストには、次の Marker 情報を入れます。

  {
      startLineNumber: range.startLineNumber,
      startColumn: range.startColumn,
      endLineNumber: range.endLineNumber,
      endColumn: range.endColumn,
      message: message,
      severity: monaco.MarkerSeverity.Error,
  }

上記を見れば各項目が何を意味するか、直感的に分かると思います。

念の為概要を説明すると、次を指定しています。

  • どこの部分にメッセージを表示するのか
  • 実際の表示するメッセージ
  • メッセージの種別

なお、一点注意すると、 setModelMarkers() の第2引数に指定する識別名は、 monaco.editor.removeAllMarkers() に指定します。

この monaco.editor.removeAllMarkers() は、 setModelMarkers() で登録した Marker を削除する際に利用します。

Marker は、 setModelMarkers() で登録したものを、 一括して setModelMarkers() で削除します。

さいごに

monaco editor への独自言語の機能追加は、かなり簡単に実現出来ます。

独自言語開発している人の Web エディタとして、オススメです。