⛄️

SATySFi Language Server を作って快適に執筆してみた話

2021/12/10に公開

この記事は SATySFi advent calendar 2021 の10日目の記事です。
昨日は登録記事がありませんでした。明日は abenori さんからの記事が投稿される予定です。

はじめに

本記事では、SATySFi の執筆・開発を支援する SATySFi language server というツールを紹介します。

プログラミング言語の開発環境は目まぐるしく進化しています。近年登場した language server protocol (LSP) という枠組みは中でも特に強力であり、Vim や Emacs、 Visual Studio Code (VSCode) といった著名なテキストエディタで広く使われるようになりました。LSP の特徴は、テキスト補完などの開発支援機能をサーバとクライアント(エディタ)の2つに分け、特定のプロトコルで互いにやり取りするという方式にあります。従来は各々のテキストエディタが言語ごとにそれぞれプラグインや拡張機能を開発する必要がありましたが、 LSP の登場により言語固有の機能はサーバ側で、テキストエディタ側の機能はクライアント側で各々開発すれば良くなりました。その結果、開発にかかる手間が大幅に減少しただけでなく、マイナーな言語や新興テキストエディタでもリッチな開発体験を気軽に提供できるようになったのです。

LSP の対象言語はプログラミング言語に限らず、 LaTeX 等のマークアップ言語、JSON や YAML 等のデータ記述言語といった領域にも広がっています。LSP を用いて SATySFi の執筆を支援する試みも、ある意味当然の流れといえます。

SATySFi は組版のための関数型言語であり、型推論が強力静的型付け言語という特徴があります。この特徴は、どちらも LSP にとって非常に相性の良いものです:

  • 静的型付け言語
    → 静的解析で得られる情報が多いため、よりリッチな補完ができる。
    • 変数の型に応じて補完候補の出し分け
    • 型検査の結果を診断情報として表示
  • 強力な型推論
    型推論が強力な言語は書くときの負担こそ少ないものの、コードリーディングの際は変数の型を人間が推論しなければならず読む際の負担が増えるケースがある。LSP を使えば推論させた変数の型情報をホバー表示させることができ、読み手の負担が軽減される。

このように、SATySFi の仕様は快適な執筆体験を提供できるポテンシャルで溢れています。SATySFi ならではの静的解析をフル活用し、他のどんなマークアップ言語よりも快適な執筆環境・開発環境を提供すること。それが SATySFi language server の目指す世界です。

SATySFi language server の提供機能

大きなことを書きましたが、 SATySFi language server はまだまだ開発の途中であり、一般の language server が提供する機能のうちごく一部しか提供できていません。今の所、提供機能は以下のとおりです。

  • 変数名の補完
    • プリミティヴ(SATySFi がデフォルトで提供する変数・関数)
    • 同一ファイル内で定義された変数
    • インポートされたパッケージ内で定義された変数
  • コマンド名の補完
  • 変数及びコマンドへの定義ジャンプ
  • Syntax Error に対する診断情報 (diagnostics) の表示
  • 変数やコマンドに対する型情報のホバー表示

SATySFi language server では、 SATySFi 本体の処理系とは別に新たな処理系を実装しています。パーサは形になっているもののまだ型推論器が実装できておらず、型検査や型推論を必要とする機能が未だ提供できていません。また SATySFi のモジュールシステムに関連する機能(モジュール下の変数・コマンド名補完など)もまだ完全には実装できていません。もっと力が欲しい。

SATySFi Language Server の利用方法

SATySFi language server は以下のリポジトリで開発されています。

https://github.com/monaqa/satysfi-language-server

SATySFi 本体は OCaml で開発されていますが、 SATySFi language server では Rust を選びました。理由は色々ありますが、一番の理由は作者(私)が個人的に Rust を勉強したかったからです。

SATySFi language server を利用するためには、まず satysfi-language-server バイナリをインストールする必要があります。Rust のコンパイラをインストールし、以下のようにしてソースからビルドしてください。

git clone https://github.com/monaqa/satysfi-language-server
cd satysfi-language-server
cargo install --path .

以下、Vim, Neovim, Emacs, Visual Studio Code (VSCode) のいずれかのテキストエディタで SATySFi language server を使う方法について述べます。Sublime Text や Atom など、他の(LSP クライアントを提供しているであろう)テキストエディタについては、導入方法がよく分かっておらずここには書いていません。実際に動かせた方がいらっしゃれば、ぜひ方法を教えていただけると助かります。

Vim/Neovim + coc.nvim で使う

coc.nvim は Vim や Neovim における代表的な LSP クライアントです。coc.nvim で SATySFi language server を用いるためには、coc-settings.json に以下のような設定を追加します。

{
  "languageserver": {
    "satysfi-ls": {
        "command": "satysfi-language-server",
        "args": [],
        "filetypes": ["satysfi"]
    }
  }
}

Vim/Neovim + vim-lsp で使う

vim-lsp もまた Vim や Neovim の著名な LSP クライアントです。私は vim-lsp を使ったことがないため正確には分かりませんが、README を読んだ限りこんな感じで設定すれば多分動くと思います。

augroup LspSatysfi
  autocmd!
  autocmd User lsp_setup call lsp#register_server({
      \ 'name': 'satysfi-ls',
      \ 'cmd': {server_info->['satysfi-language-server']},
      \ 'allowlist': ['satysfi'],
      \ })
augroup END

Neovim builtin LSP で使う(2022/01/23 追記)

Neovim builtin LSP (nvim-lsp) は Neovim v0.5 以降に標準で入っている LSP クライアントであり、Lua で設定が書けることもあってリリース以来着実に人気を伸ばしています。nvim-lsp で SATySFi language server を設定する方法を 蒲生辰巳 さんに教えていただきました。ありがとうございます!
以下の手順で設定を書けば使えるようになるそうです。

  1. <runtimepath>/lua/lspconfig/server_configurations/satysfi-ls.lua に以下の設定を追加

    satysfi-ls.lua
    local util = require 'lspconfig.util'
    
    return {
      default_config = {
        cmd = { 'satysfi-language-server' },
        filetypes = { 'satysfi' },
        root_dir = util.root_pattern('.git'),
        single_file_support = true,
      },
      docs = {
        description = [[
          https://github.com/monaqa/satysfi-language-server
          Language server for SATySFi.
          ]],
        default_config = {
          root_dir = [[root_pattern(".git")]],
        },
      },
    }
    
  2. 以下の設定を Neovim の設定に追加

    require('lspconfig')['satysfi-ls'].setup{
      autostart = true
    }
    

Emacs で使う(2021/12/20 追記)

MasWag さんが Emacs で動作させる設定手順をまとめてくださいました。ありがとうございます!

https://maswag.github.io/blog//posts/2021/12/emacs-satysfi-language-server/

上の記事は SATySFi advent calendar 2021 の13日目の記事でもあります。 Emacs ユーザもそうでない方もぜひ読んでみてください。

VSCode で使う(2021/12/20 追記)

SATySFi Workshop という拡張機能で SATySFi language server に対応していただいたそうです。開発者の pickoba さん、ありがとうございます!

https://marketplace.visualstudio.com/items?itemName=pickoba.satysfi-workshop

pickoba さんご本人が書かれた SATySFi Workshop の紹介記事はこちらです。SATySFi advent calendar 2021 の20日目の記事でもあるので、VSCode ユーザもそうでない方もぜひ読んでみてください。

https://qiita.com/pickoba/items/efe59538253c4a6ea5f8

動作している様子

上で紹介した提供機能の中でも一番「動いている」感のある補完機能を GIF 動画で紹介します。普段私が利用している Neovim + coc.nvim にて、 SLyDIFi というパッケージを読み込んでスライドを作成したときの様子を動画に撮ったものです。

上の動画では、以下の要素を補完するときに SATySFi language server が使用されました。

  • ブロックコマンド: +frame, +fig-center
  • インラインコマンド: \emph
  • 関数: include-image

補完機能は SATySFi language server が提供する最も強力な機能であり、多くの特徴を備えています。

  • モードに応じた補完候補の出し分け
    SATySFi の構文には水平モードや垂直モードなど複数のモードが存在し、モードによって書けるコマンドの種類などが変化します。SATySFi language server では現在のカーソル位置のモードを判断し、適切な補完候補だけを絞って表示させています。たとえばインラインコマンドと数式コマンドはともに \ から始まりますが、インラインテキスト中で数式コマンドの補完は表示されません。
  • 依存パッケージのコマンド・関数補完
    @require:@import: を用いて特定のヘッダファイルを読み込んでいる場合、その依存ファイルの内容を読んで補完候補を表示します。
  • コマンドの引数の型に応じたスニペット展開
    コマンドの型が予めシグネチャとして明示されている場合、引数の型に応じて補完結果を変化させ、より入力しやすくします。
    • たとえば inline-text 型の引数を1つ持つ \textbf コマンドを補完した場合、 \textbf{} と補完されます。
    • たとえば int list 型の引数と block-text 型の引数を1つずつ持つ +foo コマンドを補完した場合、 +foo[]<> と補完されます。

まだ追加したい機能は沢山ありますが、この補完機能は既にわりと実用的な域に達しており、特にコマンドを多用するマークアップを行う場合に重宝します。
今年の6月の SATySFi Conf 2021 にて登壇した際に SATySFi Language Server の現状と今後 という題目で発表させていただきましたが、このスライドを作成する際にも SATySFi language server が大いに役立ちました。なお、登壇資料のソースコードは以下に公開されています。

https://github.com/monaqa/2021-06-26-satysficonf

(ちょっとだけ)技術紹介

Language server とは「書いてあるコードをもとに静的解析を行い、いい感じの情報をユーザに提示する」ものです。

SATySFi language server では以下のような処理を実装しています。

  1. ファイルが開かれたり、中身が変更されたりしたら、ファイルを読み込んで以下の処理を行う:
    • 与えられたファイルの構文解析
    • 意味解析(もどき)
      • 文書ファイルやパッケージの依存関係の読み込み
      • 変数、モジュール、コマンドの宣言・定義の情報読み込み(依存パッケージなども遡る)
  2. クライアント側から来る以下のようなリクエストに対し、それに応じたレスポンスを投げる。

構文解析

以下のリポジトリで SATySFi のパーサを実装しています。

https://github.com/monaqa/satysfi-parser

現時点では Rust の rust-peg クレート[1] を用いて parsing expression grammar (PEG) ベースで実装しています。なお、最初は pest という PEG パーサジェネレータを用いていましたが、パフォーマンス上の理由で乗り換えています [2]

ただし現在のパーサでは柔軟なエラー回復を行うのがわりと大変であり、今後はよりテキスト入力支援と相性の良い Tree-sitter というパーサジェネレータを用いて書き直そうと考えています。
特に現在は SATySFi v0.1.0 でお披露目される予定の新構文 のパーサ実装に取り組んでおり、完全ではないものの一部の文法がパースできるようになっています。

https://github.com/monaqa/tree-sitter-satysfi/tree/grammar-v0_1_0

Language Server 本体

Rust には language server 実装を行うためのクレートがいくつか存在します。今回は lspower を用いました。
lsp-types というクレートが中に入っており、どのような値をリクエスト/レスポンスに渡せばよいか型が勝手に導いてくれるため、 rust-analyzer という Rust 言語の language server と組み合わせることで非常に気持ちよく開発できます。これもまた LSP が与える恩恵の一つですね。

詳しいコードの説明は省略しますが、主にこのあたりでクライアントから届いたリクエストや通知などを処理しています。

おわりに

皆さんも SATySFi language server を使って快適な SATySFi ライフを満喫しましょう!

最後に。
SATySFi advent calendar 2021 はまだ枠に余裕があるようです!SATySFi に興味がある方はぜひ登録してみてください。「SATySFi を使ってみた」「使ってみたけどここがよく分からなかった」「SATySFi 大喜利2021」などなど、SATySFi に関連したりしなかったりすることであれば基本的になんでも構いません。

脚注
  1. クレートとは、他言語でいうライブラリやパッケージに相当するものです。 ↩︎

  2. もう少し具体的には、pest は Packrat parsing というメモ化のような機能を備えていなかったのが直接の原因となりました。 ↩︎

Discussion