Rustコトハジメ

プログラミング言語Rustと競プロに関する情報をお届けします。

combineを使ってVSCodeのスニペットのパーサーを書く

エディタアスペなので引き続きエディタ開発をしています。

GitHub - akiradeveloper/ijk: A real editor for real programmers

エディタ開発の進捗

今はVSCode形式のスニペットを読み込んで、入力支援をするための実装を進めていて、設計としては、

  1. スニペットファイルを読み込んで辞書を構築する
  2. 入力ごとにスニペットの候補をフィルタして、表示する
  3. ユーザに選択されたスニペットを挿入して、ユーザがTabストップをジャンプしながら変更する

という3ステップに分割することを考えていて、3の実装をまず終えたところです。テスト用のキーを押すと、テスト用のforスニペットを挿入するということが出来ています。

f:id:akiradeveloper529:20190329122250g:plain

今回やること

次は1を実装します。そのためにまずは、スニペットのbodyを一行パースするパーサーを実装します。実装が難解になるわりには出現頻度が少ないため、nested tab stopとtransformは禁止することにしました。作るパーサーは、禁止されたフォーマットを読もうとすると、Noneを返すことにします。こうすることで、サポートしない形式のスニペットを除外します。

        "body": [
          "for (const ${2:element} of ${1:array}) {", // こういうやつ
          "\t$0",
          "}"
        ],

ライブラリとしては

GitHub - Marwes/combine: A parser combinator library for Rust

を使うことにします。もう一つ有名な実装としてはnomというのがありますが、自分はParsecやscala-parser-combinatorsを使ったことがあるので、近そうな方を選びました。

出来たもの

YoutubeでLIVEコーディングしながら4時間半かかりました。ドキュメントの読解からはじめて、少しずつパーサを拡張していきました。

// tabstop = $num | ${num} | ${num:placeholder}
// elem = tabstop | str
// line = elem+

#[derive(Debug, PartialEq)]
pub enum SnippetElem {
    TabStop(String, usize),
    Str(String)
}

pub struct LineParser {}
impl LineParser {
    pub fn new() -> Self {
        Self {}
    }
    pub fn parse(&mut self, s: &str) -> Option<Vec<SnippetElem>> {
        use self::SnippetElem::*;

        let num_p = many1(digit()).map(|n: String| n.parse::<usize>().unwrap());
        let placeholder_p = satisfy(|c| c != '{' && c != '}' && c != '$');

        let tabstop_p0 = num_p.clone().map(|n: usize| TabStop("".to_owned(), n));
        let tabstop_p1 = num_p.clone().skip(token(':')).and(many1::<String, _>(placeholder_p.clone())).map(|(n, s)| TabStop(s, n));
        // $n
        let p0 = string("$").with(tabstop_p0.clone());
        // ${n}
        let p1 = string("$").with(between(token('{'),token('}'),tabstop_p0.clone()));
        // ${n:s}
        let p2 = string("$").with(between(token('{'),token('}'),tabstop_p1.clone()));
        let tabstop_p = attempt(p0).or(attempt(p1)).or(p2);

        let char_p = placeholder_p.clone().or(token('{')).or(token('}'));
        let str_p = many1(char_p).map(|s| Str(s));

        let elem_p = attempt(tabstop_p).or(str_p);

        let mut line_p = many1::<Vec<SnippetElem>, _>(elem_p).skip(not_followed_by(any())); // must consume all
        line_p.parse(s).ok().map(|x| x.0)
    }
}

解説

使ってる部品について挙動を説明します。(自明なものは省きました)

  • p1.skip(p2)はp2の結果を捨ててp1の結果だけを採用します。(逆はwithです)
  • attempt(p)は、pが失敗時にトークン列を消費しないようにします。これを使わないとorが正しく動きません
  • between(p1,p2,p3)は、"p1p3p2"のようなトークン列を消費してp3を返します
  • not_followed_by(p)は、次のトークンがpで消費出来るかチェックします。フォローされていた場合は、失敗します。skip(not_followed_by(any())で、「次に何かあった場合は失敗する」という意味になります。これで、トークン列が余ってないかチェックしています

挙動

上に示した例をパースしてみましょう。

Some(
    [
        Str(
            "for (const "
        ),
        TabStop(
            "element",
            2
        ),
        Str(
            " of "
        ),
        TabStop(
            "array",
            1
        ),
        Str(
            ") {"
        )
    ]
)

感想

想像どおりですが、パーサを作っていくと巨大な型が生成されていくため、エラーメッセージが解読不能になります。

あとは、どの部品がどこにあるのかなかなか理解出来ずに手こずりました。これから使う人は、combine::parser以下に定義されている型を全部眺めることから始めることをおすすめします。