🧲

syn crateを使ってproc_macroを書く (part.1 解析編)

2022/09/24に公開

こんにちは。yharaです。私はShiikaというプログラミング言語を作っているのですが、その過程でこんなコードを書くことがありました。

extern "C" {
    #[allow(improper_ctypes)]
    pub fn Meta_Array_new(receiver: *const u8) -> SkAry<SkObj>;
}
pub fn meta_array_new(receiver: *const u8) -> SkAry<SkObj> {
    unsafe { Meta_Array_new(receiver) }
}

extern "C" {
    #[allow(improper_ctypes)]
    pub fn Meta_Class_new(receiver: *const u8) -> SkClass;
}
pub fn meta_class_new(receiver: *const u8) -> SkClass {
    unsafe { Meta_Class_new(receiver) }
} 

ここでは内容には詳しく触れませんが、要は同じようなことを何回も書く必要があるので自動化したいです[1]

本稿ではこれをproc macroでどのように実現したかを紹介します。

proc macroとは

proc macroはRustのもつ強力なマクロ機能のうちの、一番強力なやつです。プログラムの実行に先立って、RustのプログラムをRust自身で書き換えることができます。

syn crateとは

Rust本体だけだとTokenStreamという型を使ってproc macroを書くのですが、それをより簡単に扱えるようにしたParseStreamという型を提供するライブラリです。

TokenStreamは以下のような感じのデータで、Rustのコードをトークンに区切ったもの、という感じです。

[lib/shiika_ffi_macro/src/lib.rs:42] &input = TokenStream [
    Literal {
        kind: Str,
        symbol: "Meta:Class#new",
        suffix: None,
        span: #0 bytes(28324..28340),
    },
    Punct {
        ch: ',',
        spacing: Alone,
        span: #0 bytes(28340..28341),
    },
    Ident {
        ident: "fn",
        span: #0 bytes(28346..28348),
    },
    Group {
        delimiter: Parenthesis,
        stream: TokenStream [
            Ident {
                ident: "receiver",
                span: #0 bytes(28349..28357),
            },
...

これを生で扱おうと思うとエラーチェックが結構大変なのですが、synはこれのパーサを書くのを助けてくれます。

structを定義する

まずパース結果としてどのようなデータを得たいかを考えます。今回は

shiika_method_ref!(
    "Meta:Class#new",
    fn(receiver: *const u8) -> SkClass,
    "meta_class_new"
);

から

extern "C" {
    #[allow(improper_ctypes)]
    pub fn Meta_Class_new(receiver: *const u8) -> SkClass;
}
pub fn meta_class_new(receiver: *const u8) -> SkClass {
    unsafe { Meta_Class_new(receiver) }
} 

を生成したいので、

shiika_method_ref!(
    [Shiikaのメソッド名],
    fn([引数...]) -> [返り値の型],
    [Rustの関数名]
);

という風に分解できると嬉しいですね。ということでまず[Shiikaのメソッド名]の部分を取り出すことを考えます。この部分はRustの文字列リテラルを期待するので、

pub struct ShiikaMethodRef {
    method_name: syn::LitStr,
}

を用意して、

#[proc_macro]
pub fn shiika_method_ref(input: TokenStream) -> TokenStream {
    parse_macro_input!(input as ShiikaMethodRef);
    let gen = quote!{ todo };
    gen.into()
}

とすると

   Compiling skc_rustlib v0.1.0 (/Users/yhara/Dropbox/proj/shiika/lib/skc_rustlib)
error: unexpected token
  --> lib/skc_rustlib/src/sk_methods.rs:16:21
   |
16 |     "Meta:Class#new",
   |                     ^

のようになりました。「余計なカンマがある」ということは逆にいうとその前までは読めているということで、良さそうですね。

カンマを読み飛ばす

ParseBufferのメソッドには「読み飛ばす」という操作はなさそうでしたが、parseの型を適切に指定してやることで「カンマがあればそれを読み込む。なければエラーを返す」という処理ができそうなので、以下を試したらうまくいきました。

    fn parse(input: ParseStream) -> Result<Self> {
        let method_name = input.parse()?;
        let _: syn::Token![,] = input.parse()?;

同様に、続くfnも以下のようにして読み飛ばせます。

        let _: syn::Token![fn] = input.parse()?;

(読み飛ばすくらいなら最初から書かせなければ良いのではと思うかもしれませんが、Rustの関数型の文法と揃えたほうが分かりやすいと思うのでこうしました。)

括弧をパースする

続いて(を読み飛ばそうと思ったのですが、let _: Token![(] = input.parse()?;

  --> /Users/yhara/Dropbox/proj/shiika/lib/shiika_ffi_macro/src/shiika_method_ref.rs:16:23
   |
16 |         let _: Token![(] = input.parse()?;
   |                      -^^ mismatched closing delimiter
   |                      ||
   |                      |unclosed delimiter
   |                      closing delimiter possibly meant for this

error: mismatched closing delimiter: `]`

というエラーになりました。そういえばTokenStreamをdbg表示したときも括弧でくくった部分はGroupというstructでまとめられていたような気がします。

ということでsynのサンプルを見るとsyn::punctuated::Punctuatedというのが使えそうに見えます。サンプルだとItemStructのfieldsがPunctuatedなので、ItemStructのfieldsをパースしているところを参考に、以下のようにしました。

pub struct ShiikaMethodRef {
    method_name: syn::LitStr,
    parameters: Punctuated<Field, Token![,]>,
}
...
        let _: syn::token::Paren = parenthesized!(content in input); 
        let parameters = content.parse_terminated(Field::parse_named)?; 

返り値の型をパースする

ここまで来たらあと一息です。返り値の型をパースします。これはSkClassのように一つのトークンのこともありますが、SkAry<SkObj>のように複数のトークンから構成されることもあります。「Rustの型」に対応したstructがあるはずなのでそれを探しましょう。

syn::Fieldで関数の引数をパースできることがわかったので、その中に型を表す部分があるはずです。syn::Typeというのがあります。これですね。

ということで以下のようになりました。

use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{parenthesized, Field, Result, Token};

/// Helper struct for `shiika_method_ref` macro
pub struct ShiikaMethodRef {
    method_name: syn::LitStr,
    parameters: Punctuated<Field, Token![,]>,
    ret_ty: syn::Type,
    rust_func_name: syn::LitStr,
}

impl Parse for ShiikaMethodRef {
    fn parse(input: ParseStream) -> Result<Self> {
        let method_name = input.parse()?;
        let _: Token![,] = input.parse()?;
        let _: Token![fn] = input.parse()?;
        let content;
        let _: syn::token::Paren = parenthesized!(content in input);
        let parameters = content.parse_terminated(Field::parse_named)?;
        let _: Token![->] = input.parse()?;
        let ret_ty = input.parse()?;
        let _: Token![,] = input.parse()?;
        let rust_func_name = input.parse()?;
        Ok(ShiikaMethodRef {
            method_name,
            parameters,
            ret_ty,
            rust_func_name,
        })
    }
}

長くなってきたのでpart.2に続きます。

脚注
  1. というだけなら単にものぐさなだけですがもう一つ理由があって、「Meta_Array_new」という名前はShiikaコンパイラが一定の規則に従って自動生成するものなので、その生成ルールを変える必要が生じたときに大量の手作業が生じないようにマクロ化しておきたい。という事情なのでした。 ↩︎

Discussion