[Rust] proc_macroを書いてsynと仲良くなる
proc_macroを書いていて、設定を受け取りたい時があり
darling を使おうかと思ったんですが
イメージどおりの書き方ができなそうだったので
独自構文をパースしたいなということで、synと少し仲良くなってみたお話です。
長くなってしまうので、いろいろ端折ってポイントだけを書いているので
↑完成形はこちらです。
環境
- 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を有効にする
[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を依存に追加する
...
[dependencies]
quote = "1.0.23"
syn = { version = "1.0.109", features = ["extra-traits", "parsing", "derive"] }
最低限コンパイルが通る事を目指す
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が無いと怒られました
受け取れるようにします。
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を取得する
こちらの記事を参考にさせて頂きました 🙏 ありがとうございます 🙇
まずは、受け取った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,
},
]
name
に NewHoge
が入り
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,
),
]
無事、NewHoge
と name
が取り出せました。
テストを通す
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>,
}
まずは OmitOption
に DeriveOption
を持たせるようにして
#[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_option
に Debug
が入っていることを確認するテストにしてみました。
※ Path
は PartialEq<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