🧙

[Rust] proc_macroを書いてsynと仲良くなる

2023/03/11に公開

proc_macroを書いていて、設定を受け取りたい時があり
darling を使おうかと思ったんですが
イメージどおりの書き方ができなそうだったので
独自構文をパースしたいなということで、synと少し仲良くなってみたお話です。
長くなってしまうので、いろいろ端折ってポイントだけを書いているので
https://github.com/yagince/evil-rs
↑完成形はこちらです。

環境

  • Rust: 1.67
  • macOS: Ventura 13.2.1

ゴール

#[derive(Omit)]
#[omit(NewHoge, name, derive(Debug))]
struct Hoge {
    id: u64,
    name: String,
}

こんな感じで定義すると

#[derive(Debug)]
struct NewHoge {
    id: u64,
}

という構造体が生成されるのを目指します。

※今回はsynでパースする所まで。

Cargo.tomlでproc-macroを有効にする

Cargo.toml
[package]
name = "proc-macro-syn-example"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
proc-macro = true

[dependencies]

テストを書いてみる

テストといってもまずはコンパイルが通ることを確認するだけのもの

use proc_macro_syn_example::Omit;

#[derive(Omit)]
#[omit(NewHoge, name)]
struct Hoge {
    id: u64,
    name: String,
}
error: cannot find derive macro `Omit` in this scope
 --> tests/test.rs:1:10
  |
1 | #[derive(Omit)]
  |          ^^^^

error: cannot find attribute `omit` in this scope
 --> tests/test.rs:2:3
  |
2 | #[omit(NewHoge, name)]
  |   ^^^^

error: could not compile `proc-macro-syn-example` due to 2 previous errors

当然ですが、失敗します。

まずは derive(Omit) できるようにする

quoteとsynを依存に追加する

Cargo.toml
...
[dependencies]
quote = "1.0.23"
syn = { version = "1.0.109", features = ["extra-traits", "parsing", "derive"] }

最低限コンパイルが通る事を目指す

lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(Omit)]
pub fn derive_omit(_input: TokenStream) -> TokenStream {
    quote!().into()
}

テストしてみましょう

error: cannot find attribute `omit` in this scope
 --> tests/test.rs:4:3
  |
4 | #[omit(NewHoge, name)]
  |   ^^^^

error: could not compile `proc-macro-syn-example` due to previous error

omit っていうattributeが無いと怒られました
受け取れるようにします。

lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(Omit, attributes(omit))]
pub fn derive_omit(_input: TokenStream) -> TokenStream {
    quote!().into()
}

attributes(omit) を追加しました。

warning: struct `Hoge` is never constructed
 --> tests/test.rs:5:8
  |
5 | struct Hoge {
  |        ^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: `proc-macro-syn-example` (test "test") generated 1 warning
    Finished test [unoptimized + debuginfo] target(s) in 0.18s
     Running unittests src/lib.rs (target/debug/deps/proc_macro_syn_example-405510c0b7c3b8a1)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/test.rs (target/debug/deps/test-ad50b270d7d358fd)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests proc-macro-syn-example

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Hogeを使っていないので、警告が出ますが、コンパイルは通るようになりました。

Attributeを取得する

https://zenn.dev/frozenlib/articles/parse_buffer_peek
こちらの記事を参考にさせて頂きました 🙏 ありがとうございます 🙇

まずは、受け取ったTokenStreamをDriveInputに変換して中身を見てみます。

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Omit, attributes(omit))]
pub fn derive_omit(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    dbg!(input);
    quote!().into()
}
[src/lib.rs:8] input = DeriveInput {
    attrs: [
        Attribute {
            pound_token: Pound,
            style: Outer,
            bracket_token: Bracket,
            path: Path {
                leading_colon: None,
                segments: [
                    PathSegment {
                        ident: Ident {
                            ident: "omit",
                            span: #0 bytes(53..57),
                        },
                        arguments: None,
                    },
                ],
            },
            tokens: TokenStream [
                Group {
                    delimiter: Parenthesis,
                    stream: TokenStream [
                        Ident {
                            ident: "NewHoge",
                            span: #0 bytes(58..65),
                        },
                        Punct {
                            ch: ',',
                            spacing: Alone,
                            span: #0 bytes(65..66),
                        },
                        Ident {
                            ident: "name",
                            span: #0 bytes(67..71),
                        },
                    ],
                    span: #0 bytes(57..72),
                },
            ],
        },
    ],
    vis: Inherited,
    ident: Ident {
        ident: "Hoge",
        span: #0 bytes(81..85),
    },
    generics: Generics {
        lt_token: None,
        params: [],
        gt_token: None,
        where_clause: None,
    },
    data: Struct(
    ...

長いので省略しましたが、attrs の中に Attribute 型として入っているみたいですね。
Attribute の中は

  • path として attribute名が入っている
  • tokens の中にarrtibuteの中身が入っている

という感じになってそうです。

synを使ってAttributeの中身をパースする

attributeの中身をパースして、構造体にマッピングしてみたいと思います。

use syn::Ident;
struct OmitOption {
    /// 新しく定義する型の名前
    pub name: Ident,
    /// 除外するfield
    pub fields: Vec<Ident>,
}

こんな感じの構造体にマッピングすることを目指します。

テストを書く

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_omit_option() {
        let token = quote! {
            (NewHoge, name)
        };

        let ret = syn::parse2::<OmitOption>(token);
        assert_eq!(ret.name, "NewHoge");
        assert_eq!(ret.fields, vec!["name"]);
    }
}

syn::parse2 を使って、OmitOptionにパースします。

Parseトレイトを実装していく

synでパースできるようにするするために
Parse in syn::parse
このトレイトを実装します。

impl Parse for OmitOption {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        dbg!(input);
        todo!()
    }
}

こんな感じでまずは中身を見てみましょう。

---- tests::test_parse_omit_option stdout ----
[src/lib.rs:21] input = TokenStream [
    Group {
        delimiter: Parenthesis,
        stream: TokenStream [
            Ident {
                sym: NewHoge,
            },
            Punct {
                char: ',',
                spacing: Alone,
            },
            Ident {
                sym: name,
            },
        ],
    },
]

Group という型で delimiter が括弧(Parenthesis)ですよっていう感じになってそうですね。

括弧をはずして中身のみを取得する

use syn::{
    parenthesized,
    parse::{Parse, ParseStream},
    token::Paren,
};

impl Parse for OmitOption {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        dbg!(input);

        // 括弧の中身だけをcontentに入れる
        let content;
        let _: Paren = parenthesized!(content in input);
        dbg!(content);

        todo!()
    }
}
[src/lib.rs:28] input = TokenStream [
    Group {
        delimiter: Parenthesis,
        stream: TokenStream [
            Ident {
                sym: NewHoge,
            },
            Punct {
                char: ',',
                spacing: Alone,
            },
            Ident {
                sym: name,
            },
        ],
    },
]
[src/lib.rs:33] content = TokenStream [
    Ident {
        sym: NewHoge,
    },
    Punct {
        char: ',',
        spacing: Alone,
    },
    Ident {
        sym: name,
    },
]

こんな感じで、() の中身だけが取り出せたので

(NewHoge, name)

から

NewHoge, name

の状態になりました。

Identを取得する

NewHoge, name から NewHoge を取得してみます

impl Parse for OmitOption {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        // 括弧の中身だけをcontentに入れる
        let content;
        let _: Paren = parenthesized!(content in input);

        // Hoge, name の Hoge を取得する
        let name: Ident = content.parse()?;
        dbg!(name);
        dbg!(content);

        todo!()
    }
}

ParseStreamの正体は
ParseBufferなので

pub fn parse<T: Parse>(&self) -> Result<T>

paese メソッドで Parse が実装されている任意の型にパースできるっぽいです。
これをつかって syn::Ident にパースします。

これによって、contentから NewHoge が抜かれます。

[src/lib.rs:34] name = Ident(
    NewHoge,
)
[src/lib.rs:35] content = TokenStream [
    Punct {
        char: ',',
        spacing: Alone,
    },
    Ident {
        sym: name,
    },
]

nameNewHoge が入り
content, name になっていることが確認できました。

, を取得して、その次のIdentを取得する

さて、次は name が欲しいんですが , が邪魔なので
, をパースしてスキップしたいです

use syn::{
    parenthesized,
    parse::{Parse, ParseStream},
    token::Paren,
    Token,
};

impl Parse for OmitOption {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        // 括弧の中身だけをcontentに入れる
        let content;
        let _: Paren = parenthesized!(content in input);

        // Hoge, name の Hoge を取得する
        let name: Ident = content.parse()?;

        let _: Token![,] = content.parse()?;

        let field: Ident = content.parse()?;

        dbg!([name, field]);

        todo!()
    }
}

Tokenというマクロで主要なキーワードが定義されているのでこれを使いました。

---- tests::test_parse_omit_option stdout ----
[src/lib.rs:40] [name, field] = [
    Ident(
        NewHoge,
    ),
    Ident(
        name,
    ),
]

無事、NewHogename が取り出せました。

テストを通す

impl Parse for OmitOption {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        // 括弧の中身だけをcontentに入れる
        let content;
        let _: Paren = parenthesized!(content in input);

        // Hoge, name の Hoge を取得する
        let name: Ident = content.parse()?;

        let _: Token![,] = content.parse()?;

        let field: Ident = content.parse()?;

        Ok(OmitOption {
            name,
            fields: vec![field],
        })
    }
}
running 1 test
test tests::test_parse_omit_option ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

無事パスしました。

これでfieldが1個のパターンは対応できました。
※複数あるパターンは今回は割愛します。

構文が間違っているとどうなるのか?

テストしてみます。

    #[test]
    fn test_parse_omit_option_invalid_syntax() {
        let token = quote! {
            (NewHoge,)
        };

        let ret = dbg!(syn::parse2::<OmitOption>(token));
        assert_eq!(ret.is_err(), false);
    }

※わざとテストがfailするようにしてあります。

[src/lib.rs:72] syn::parse2::<OmitOption>(token) = Err(
    Error(
        "unexpected end of input, expected identifier",
    ),
)

こんなエラーが返ってきました。
なるほど。

独自のキーワードを使えるようにしてみる

新しい型にderiveさせる指定をできるようにしてみたいと思います。

(NewHoge, name, derive(Debug, std::clone::Clone))

みたいな感じで指定したいので

derive(Debug, std::clone::Clone)

まずはこの部分だけを考えていきます。

ここは

#[derive(Debug, Clone, PartialEq)]
struct DeriveOption {
    pub derives: Vec<Path>,
}

こんなシンプルな構造体にマッピングしてみます。
今回は Debug などだけではなく std::clone::Clone のような感じでも扱えるようにしたいので
Path を使います。

テストを書いてみる

    #[test]
    fn test_parse_derive_option() {
        let token = quote! {
            derive(Debug, std::clone::Clone)
        };

        let ret = dbg!(syn::parse2::<DeriveOption>(token)).unwrap();
        assert_matches!(&ret.derives[..], [first, second] => {
            assert_eq!(first.to_token_stream().to_string(), "Debug");
            assert_eq!(second.to_token_stream().to_string(), "std :: clone :: Clone");
        });
    }

独自キーワードの定義

derive は Tokenマクロに定義されてません。
Tokenマクロに定義されていないキーワードは
custom_keywordマクロを使って定義できるようです。

mod kw {
    syn::custom_keyword!(derive);
}

一応ネームスペースを分けておいた方がわかりやすそうなので kw っていうmoduleを切ってみました。

Parseトレイトを実装する

impl Parse for DeriveOption {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        let derive_content;
        let _: kw::derive = input.parse()?;
        let _: Paren = parenthesized!(derive_content in input);

        let mut derives = vec![];

        loop {
            if derive_content.is_empty() {
                break;
            }
            if derive_content.peek(Ident) {
                derives.push(derive_content.call(Path::parse_mod_style)?);
                if derive_content.peek(Token![,]) {
                    let _: Token![,] = derive_content.parse()?;
                }
            } else {
                break;
            }
        }

        Ok(DeriveOption { derives })
    }
}

先程とほぼ同じような流れなので、細かい部分は割愛します。
複数の設定を受け取れるようにloopで中身が空になるまで処理しています。

            if derive_content.peek(Ident) {
                derives.push(derive_content.call(Path::parse_mod_style)?);
                if derive_content.peek(Token![,]) {
                    let _: Token![,] = derive_content.parse()?;
                }

ここをちょっと見て行きます。

peek でその先になにがあるかを確認する

Bufferの中に次になにかあるかを確かめたい時に使えるのが ParseBuffer::peek です。

ここで derive_content の中身を見てみます。

[src/lib.rs:67] &derive_content = TokenStream [
    Ident {
        sym: Debug,
    },
    Punct {
        char: ',',
        spacing: Alone,
    },
    Ident {
        sym: std,
    },
    Punct {
        char: ':',
        spacing: Joint,
    },
    Punct {
        char: ':',
        spacing: Alone,
    },
    Ident {
        sym: clone,
    },
    Punct {
        char: ':',
        spacing: Joint,
    },
    Punct {
        char: ':',
        spacing: Alone,
    },
    Ident {
        sym: Clone,
    },
]

最初はこんな感じになっています。
なので、

  • Ident が来ていれば、そこから Path として解釈できないといけない
  • Path になれなければ構文エラーにする

という感じにしてみました。

            if derive_content.peek(Ident) {
                derives.push(derive_content.call(Path::parse_mod_style)?);

この部分です。

次に

  • ,」 があればパースする

ということをやっています。

                if derive_content.peek(Token![,]) {
                    let _: Token![,] = derive_content.parse()?;
                }

これは、末尾カンマは省略したい意図です。

let _: syn::Result<Token![,]> = derive_content.parse();

として、 Errを無視 してしまっても良いかもしれないですね。

OmitOptionのパースにDeriveOptionを組み込む

最後に、OmitOptionのParseにDeriveOptionのParseを組み込んでみます。

テストを書いてみる

#[derive(Debug, Clone, PartialEq)]
struct OmitOption {
    /// 新しく定義する型の名前
    pub name: Ident,
    /// 除外するfield
    pub fields: Vec<Ident>,
    /// 新しく定義する型に付与するderive
    pub derive_option: Option<DeriveOption>,
}

まずは OmitOptionDeriveOption を持たせるようにして

    #[test]
    fn test_parse_omit_option_with_derive() {
        let token = quote! {
            (NewHoge, name, derive(Debug))
        };

        let ret = dbg!(syn::parse2::<OmitOption>(token)).unwrap();
        assert_eq!(ret.name, "NewHoge");
        assert_eq!(ret.fields, vec!["name"]);
        assert_matches!(ret.derive_option, Some(x) => {
            assert_matches!(&x.derives[..], [d] => {
                assert!(d.is_ident("Debug"));
            });
        });
    }

derive_optionDebug が入っていることを確認するテストにしてみました。
PathPartialEq<AsRef<str>> が実装されていないので、テストするのが面倒...

OmitOption::parse を修正する

impl Parse for OmitOption {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let content;
        let _: Paren = parenthesized!(content in input);
        let name = content.parse()?;
        let _: syn::Result<Token![,]> = content.parse();

        let mut ignores = vec![];
        let mut derive_option = None;

        loop {
            if content.is_empty() {
                break;
            }

            if content.peek(kw::derive) {
                derive_option = Some(content.parse()?);
            } else if content.peek(Ident) {
                ignores.push(content.parse()?);
            } else {
                break;
            }

            if content.peek(Token![,]) {
                let _: Token![,] = content.parse()?;
            }
        }

        Ok(OmitOption {
            name,
            fields: ignores,
            derive_option,
        })
    }
}

最終的にはこんな感じになりました。

まとめ

  • syn を使うと、独自構文を作れる
    • 独自がいいか悪いかは別問題、ケースバイケース
  • 基本的に単体テストはしっかり書けるが、Path周りが面倒なのが難点
  • これでRustでも黒魔術師になれそう
    • 良い勉強になりました

Discussion