https://blog.naskya.net/

[[ 🗃 ^wzWnj blog ]] :: [📥 Inbox] [📤 Outbox] [💥 Errbox] [🐤 Followers] [🤝 Collaborators] [🏗 Projects] [🛠 Commits]

Clone

HTTPS: git clone https://code.naskya.net/repos/wzWnj

SSH: git clone USERNAME@code.naskya.net:wzWnj

Branches

Tags

main :: content / post /

a8ds0axoq6b6.md

まえがき

最近 Rust をよく書いています。Rust にはコード{{< note Rust の文法として正しい必要は無く、パース可能なトークンの列であればよい >}}を受け取ってコードを出力する関数を書いてコードの加工を行う、C 言語のマクロの超強化版のような仕組みがあります。

この機能は declarative macros と procedural macros に大別されます。特に後者のマクロはできることが多彩ですが、初見で読み書きすることは困難です。私は暫くの間書き方も読み方も分からずに知らんぷりしていましたが、はじめの一歩を踏み出してみると自分でマクロを書くことは意外と難しくないことに気づきました。

私の第一歩はこの YouTube 動画{{< link https://www.youtube.com/watch?v=geovSK3wMB8 >}}でした。この動画の最初の 3 時間くらいを 1.5 倍速くらいで流し見しながら手元で何度か写経すると procedural macros の書き方がなんとなく分かってくると思うので、入門に躓いている人にはおすすめです。

この記事では作ったマクロを一つ紹介します。このマクロは、error-doc クレート として公開されています。

目的

thiserror クレートを用いて作られた、エラーの種類を表す列挙型に対して作用させ、エラーメッセージからドキュメントを生成するマクロを作成します。ドキュメントが既にあるものについては追加でドキュメントを生成しません。

// これを
#[derive(thiserror::Error, Debug)]
enum Error {
    #[error("failed to read the file")]
    ReadFile(#[from] std::io::Error),
    #[error("account id must start with @")]
    InvalidId,
    /// database error
    #[error(transparent)]
    Db(#[from] sea_orm::DbErr),
}

// こうしたい
#[derive(thiserror::Error, Debug)]
enum Error {
    /// failed to read the file
    #[error("failed to read the file")]
    ReadFile(#[from] std::io::Error),
    /// account id must start with @
    #[error("account id must start with @")]
    InvalidId,
    /// database error
    #[error(transparent)]
    Db(#[from] sea_orm::DbErr),
}

もちろんエラーメッセージとドキュメントは異なるものなので普通はこういうことをするべきではありませんが、ドキュメントは無いよりはあったほうがいいですし、コードのメンテナンスに掛けられる手間も限られているため、このようなマクロが欲しくなりました。

3 本のスラッシュで始まる /// doc comment#[doc = "doc comment"] という属性と等価であるという知識を前提とします。

// この 2 つは同じ

/// This is a pen.
const PEN: &str = "pen";

#[doc = "This is a pen."]
const PEN: &str = "pen";

作ってみる

{{}}. セットアップ

まず、以下のようなワークスペースを用意します。errors というクレートを作り、マクロは errors/src/lib.rs に書いていきますが、その外側にももう一つクレートを作っておきます。

.
├── Cargo.toml
├── errors
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── src
    └── main.rs

外側のクレートの依存クレートに内側のクレートを追加しておきます:

$ cat ./Cargo.toml

[package]
name = "macro-test"
version = "0.1.0"
edition = "2021"
rust-version = "1.74.0"

[dependencies]
errors = { path = "errors" }
thiserror = "1.0.63"

内側のクレートでは proc_macro = true を指定し、必要な依存クレートを追加しておきます:

$ cat ./errors/Cargo.toml

[package]
name = "errors"
version = "0.1.0"
edition = "2021"
rust-version = "1.74.0"

[lib]
proc-macro = true

[dependencies]
syn = { version = "2.0.70", features = ["full", "extra-traits"] }
quote = "1.0.36"
proc-macro2 = "1.0.86"

errors/src/lib.rs には雛形を書いておきます:

use proc_macro2::TokenStream;
use quote::quote;

#[proc_macro_attribute]
pub fn errors(_attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
    match errors_impl(item.into()) {
        Ok(ts) => ts.into(),
        Err(err) => err.to_compile_error().into(),
    }
}

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    Ok(quote! {})
}

{{}}. コードをパースする

マクロが作用する対象のコードは errors_impl 関数の item という引数として渡ってくるので、これが何者なのかをまず確認してみます(以降、何の断りもなく登場する Rust のコードは errors/src/lib.rs の中の errors_impl 関数の実装です)。

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    println!("{:#?}", item);
    Ok(quote! {})
}

src/main.rs を以下のように書き換え、cargo run を実行します:

#[errors::errors]
enum Error {
    Item1,
    Item2,
}

fn main() {}

すると item の正体がターミナルに出力されます。確かにマクロが作用する対象のトークンの列を受け取っていることが分かります。

TokenStream [
    Ident {
        ident: "enum",
        span: #0 bytes(18..22),
    },
    Ident {
        ident: "Error",
        span: #0 bytes(23..28),
    },
    Group {
        delimiter: Brace,
        stream: TokenStream [
            Ident {
                ident: "Item1",
                span: #0 bytes(35..40),
            },
            Punct {
                ch: ',',
                spacing: Alone,
                span: #0 bytes(40..41),
            },
            Ident {
                ident: "Item2",
                span: #0 bytes(46..51),
            },
            Punct {
                ch: ',',
                spacing: Alone,
                span: #0 bytes(51..52),
            },
        ],
        span: #0 bytes(29..54),
    },
]

この形のままでは扱いづらいので、以下では作用する対象が列挙型であると仮定して、この TokenStreamsyn::ItemEnum として再解釈します:

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let item: syn::ItemEnum = syn::parse2(item)?;
    println!("{:#?}", item);
    Ok(quote! {})
}

cargo run の実行結果{{< note 厳密には、cargo run を実行して errors クレートがコンパイルされる際に println!(...) が実行されて出力された結果 >}}は以下のようになります:

ItemEnum {
    attrs: [],
    vis: Visibility::Inherited,
    enum_token: Enum,
    ident: Ident {
        ident: "Error",
        span: #0 bytes(23..28),
    },
    generics: Generics {
        lt_token: None,
        params: [],
        gt_token: None,
        where_clause: None,
    },
    brace_token: Brace,
    variants: [
        Variant {
            attrs: [],
            ident: Ident {
                ident: "Item1",
                span: #0 bytes(35..40),
            },
            fields: Fields::Unit,
            discriminant: None,
        },
        Comma,
        Variant {
            attrs: [],
            ident: Ident {
                ident: "Item2",
                span: #0 bytes(46..51),
            },
            fields: Fields::Unit,
            discriminant: None,
        },
        Comma,
    ],
}

src/main.rs に変更を加えて、実際に使用する際の形に近づけてみます。

#[errors::errors]
#[derive(thiserror::Error, Debug)]
enum Error {
    #[error("error one")]
    Item1,
    #[error("error two")]
    Item2,
}

fn main() {}

touch errors/src/lib.rs && cargo run を実行する{{< note touch コマンドは errors クレートを再コンパイルさせるためのもの >}}と以下の結果を得ます:

ItemEnum {
    attrs: [
        Attribute {
            pound_token: Pound,
            style: AttrStyle::Outer,
            bracket_token: Bracket,
            meta: Meta::List {
                path: Path {
                    leading_colon: None,
                    segments: [
                        PathSegment {
                            ident: Ident {
                                ident: "derive",
                                span: #0 bytes(20..26),
                            },
                            arguments: PathArguments::None,
                        },
                    ],
                },
                delimiter: MacroDelimiter::Paren(
                    Paren,
                ),
                tokens: TokenStream [
                    Ident {
                        ident: "thiserror",
                        span: #0 bytes(27..36),
                    },
                    Punct {
                        ch: ':',
                        spacing: Joint,
                        span: #0 bytes(36..37),
                    },
                    Punct {
                        ch: ':',
                        spacing: Alone,
                        span: #0 bytes(37..38),
                    },
                    Ident {
                        ident: "Error",
                        span: #0 bytes(38..43),
                    },
                    Punct {
                        ch: ',',
                        spacing: Alone,
                        span: #0 bytes(43..44),
                    },
                    Ident {
                        ident: "Debug",
                        span: #0 bytes(45..50),
                    },
                ],
            },
        },
    ],
    vis: Visibility::Inherited,
    enum_token: Enum,
    ident: Ident {
        ident: "Error",
        span: #0 bytes(58..63),
    },
    generics: Generics {
        lt_token: None,
        params: [],
        gt_token: None,
        where_clause: None,
    },
    brace_token: Brace,
    variants: [
        Variant {
            attrs: [
                Attribute {
                    pound_token: Pound,
                    style: AttrStyle::Outer,
                    bracket_token: Bracket,
                    meta: Meta::List {
                        path: Path {
                            leading_colon: None,
                            segments: [
                                PathSegment {
                                    ident: Ident {
                                        ident: "error",
                                        span: #0 bytes(72..77),
                                    },
                                    arguments: PathArguments::None,
                                },
                            ],
                        },
                        delimiter: MacroDelimiter::Paren(
                            Paren,
                        ),
                        tokens: TokenStream [
                            Literal {
                                kind: Str,
                                symbol: "error one",
                                suffix: None,
                                span: #0 bytes(78..89),
                            },
                        ],
                    },
                },
            ],
            ident: Ident {
                ident: "Item1",
                span: #0 bytes(96..101),
            },
            fields: Fields::Unit,
            discriminant: None,
        },
        Comma,
        Variant {
            attrs: [
                Attribute {
                    pound_token: Pound,
                    style: AttrStyle::Outer,
                    bracket_token: Bracket,
                    meta: Meta::List {
                        path: Path {
                            leading_colon: None,
                            segments: [
                                PathSegment {
                                    ident: Ident {
                                        ident: "error",
                                        span: #0 bytes(109..114),
                                    },
                                    arguments: PathArguments::None,
                                },
                            ],
                        },
                        delimiter: MacroDelimiter::Paren(
                            Paren,
                        ),
                        tokens: TokenStream [
                            Literal {
                                kind: Str,
                                symbol: "error two",
                                suffix: None,
                                span: #0 bytes(115..126),
                            },
                        ],
                    },
                },
            ],
            ident: Ident {
                ident: "Item2",
                span: #0 bytes(133..138),
            },
            fields: Fields::Unit,
            discriminant: None,
        },
        Comma,
    ],
}

一気に内容が複雑になりましたが、怯む必要はありません。必要な情報(“error one” と “error two”)はここに入っているので、どうにかしてこれを取り出して #[doc = "error one"]#[doc = "error two"] を追加すれば目的は達成されます。

{{}}. 色々実験してみる

さっきの長い出力から、注目すべき部分のみを抜き出すとこんな感じです:

ItemEnum {
    variants: [
        Variant {
            attrs: [
                Attribute {
                    meta: Meta::List {
                        path: Path {
                            leading_colon: None,
                            segments: [
                                PathSegment {
                                    ident: Ident {
                                        ident: "error",
                                        span: #0 bytes(72..77),
                                    },
                                    arguments: PathArguments::None,
                                },
                            ],
                        },
                        tokens: TokenStream [
                            Literal {
                                kind: Str,
                                symbol: "error one",
                                suffix: None,
                                span: #0 bytes(78..89),
                            },
                        ],
                    },
                },
            ],
        },
        Variant {
            attrs: [
                Attribute {
                    meta: Meta::List {
                        path: Path {
                            leading_colon: None,
                            segments: [
                                PathSegment {
                                    ident: Ident {
                                        ident: "error",
                                        span: #0 bytes(109..114),
                                    },
                                    arguments: PathArguments::None,
                                },
                            ],
                        },
                        tokens: TokenStream [
                            Literal {
                                kind: Str,
                                symbol: "error two",
                                suffix: None,
                                span: #0 bytes(115..126),
                            },
                        ],
                    },
                },
            ],
        },
    ],
}

この variants.iter() で走査できるので、どのようにしたら欲しい情報を取り出せるのか実験してみましょう。

きちんと Rust 向けにエディタを設定していれば、variant. と打った後に ident などの補完候補が表れるはずなので、それを覗いてみましょう。ただ println!() するだけでは dead code としてコード自体が削除されてしまうので、.map() の後に適当に .count() などをつけてコンパイラを騙しておきます:

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let item: syn::ItemEnum = syn::parse2(item)?;
    
    item.variants.iter().map(|variant| {
        println!("{}", variant.ident);
    }).count();

    Ok(quote! {})
}

cargo run を実行すると、以下の出力を得ます。よく見れば、これはさっきの長い出力の中の variant.ident にちゃんと対応していることが分かります。

Item1
Item2

そうと分かれば話は早い。今度はどうすれば "error" の部分を出力させられるか、さっきの長い出力とエディタの補完候補を見ながら試行錯誤してみましょう。

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let item: syn::ItemEnum = syn::parse2(item)?;
    
    item.variants.iter().map(|variant| {
        variant.attrs.iter().map(|attr| {
            println!("{}", attr.meta.path().segments[0].ident);
        }).count()
    }).count();

    Ok(quote! {})
}

出力:

error
error

ではこの調子で本題のエラーメッセージも出力できるかというと、そういうわけにもいきません。暫く格闘して心が折れてきたら、ネットの海を検索して他人の知恵を借りることにしましょう: https://users.rust-lang.org/t/how-to-parse-the-value-of-a-macros-helper-attribute/39882

どうやら、attr には .parse_args() という便利な関数があるようです。また、is_ident()#[foo(bar)]foo の部分をチェックできることも分かりました。これは大きな成果です。

今回 #[error("error message")] で受け取りたいのは文字列リテラル("error message" の部分)なので、中身は syn::LitStr として受け取るのがよいでしょう。

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let item: syn::ItemEnum = syn::parse2(item)?;
    
    item.variants.iter().map(|variant| {
        variant.attrs.iter().map(|attr| {
            let lit: syn::LitStr = attr.parse_args().unwrap();
            println!("{}", lit.value());
        }).count()
    }).count();

    Ok(quote! {})
}

出力:

error one
error two

欲しい文字列を取得できました!

一般に、一つの enum variant に対して複数の attr がある可能性がある(そのため attrs に格納されている)ので、さっき学んだ .is_ident() を利用して、欲しい値だけを出力させることにしましょう。

src/main.rs を以下のように書き換えます。

#[errors::errors]
#[derive(thiserror::Error, Debug)]
enum Error {
    #[foo("foo")]
    #[bar]
    #[error("error one")]
    Item1,
    #[doc = "document"]
    #[error("error two")]
    Item2,
}

fn main() {}
fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let item: syn::ItemEnum = syn::parse2(item)?;
    
    item.variants.iter().map(|variant| {
        variant.attrs.iter().filter_map(|attr| {
            if !attr.path().is_ident("error") {
                return None;  // do nothing
            }

            let lit: syn::LitStr = attr.parse_args().unwrap();
            println!("{}", lit.value());

            Some(())
        }).count()
    }).count();

    Ok(quote! {})
}

関係無い部分の処理はスキップされるので、cargo run の出力は変わりません:

error one
error two

ここで元々やりたかったことを思い出すと、#[doc = "document"] が既にある enum variant に対しては処理をスキップしたいのでした。これを実装してみます。

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let item: syn::ItemEnum = syn::parse2(item)?;
    
    item.variants.iter().map(|variant| {
        if variant.attrs.iter().any(|attr| attr.path().is_ident("doc")) {
            1  // do nothing
        } else {
            variant.attrs.iter().filter_map(|attr| {
                if !attr.path().is_ident("error") {
                    return None;  // do nothing
                }

                let lit: syn::LitStr = attr.parse_args().unwrap();
                println!("{}", lit.value());

                Some(())
            }).count()
        }
    }).count();

    Ok(quote! {})
}

src/main.rs の中の Item2 には #[doc = "document"] が既にあるので、処理はスキップされます:

error one

{{}}. マクロを実装する

さて、そろそろコンパイラを騙して実験するのをやめて実際にマクロを作ってみます。抽出したこのエラーメッセージを #[doc = "..."] の形で attrs に追加すればよいはずです。

items を mutable な変数にして、attrs.push(...) でドキュメントを追加してみます。

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let mut item: syn::ItemEnum = syn::parse2(item)?;
    
    item.variants = item.variants.iter().map(|mut variant| {
        // check if doc attribute is alredy there
        if variant.attrs.iter().any(|attr| attr.path().is_ident("doc")) {
            return variant;
        }

        let msg = variant.attrs.iter().find_map(|attr| {
            if !attr.path().is_ident("error") {
                return None;
            }
            let lit: syn::LitStr = attr.parse_args().ok()?;
            Some(lit.value())
        });

        // add #[doc] attribute
        if let Some(msg) = msg {
            variant.attrs.push(quote! { #[doc = #msg] });
        }

        variant
    })
    .collect();

    Ok(quote! { #item })
}

うーん、うまくいかない!

variant.attrs.push(quote! { #[doc = #msg] });

の部分でエラーが出てしまいます:

error[E0308]: mismatched types
  --> errors/src/lib.rs:30:32
   |
30 |             variant.attrs.push(quote! { #[doc = #msg] });
   |                                ^^^^^^^^^^^^^^^^^^^^^^^^ expected `Attribute`, found `TokenStream`
   |

中身を quote! { #[doc = #msg] }.into() とかに変えてあがいてみますがうまくいきません。ここでまた暫くネットサーフィンをすると、ここでは quote! ではなく syn::parse_quote! を使う必要があることが分かります。

fn errors_impl(item: TokenStream) -> syn::Result<TokenStream> {
    let mut item: syn::ItemEnum = syn::parse2(item)?;

    item.variants = item.variants.into_iter().map(|mut variant| {
        // check if doc attribute is alredy there
        if variant.attrs.iter().any(|attr| attr.path().is_ident("doc")) {
            return variant;
        }

        let msg = variant.attrs.iter().find_map(|attr| {
            if !attr.path().is_ident("error") {
                return None;
            }
            let lit: syn::LitStr = attr.parse_args().ok()?;
            Some(lit.value())
        });

        // add #[doc] attribute
        if let Some(msg) = msg {
            variant.attrs.push(syn::parse_quote! { #[doc = #msg] });
        }

        variant
    })
    .collect();

    Ok(quote! { #item })
}

これでようやくうまくいきました。試しに src/main.rs を以下のように編集して、cargo doc でドキュメントを生成してみます:

#[errors::errors]
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("error one")]
    Item1,
    #[doc = "document for Item2"]
    #[error("error two")]
    Item2,
}

fn main() {}

確かに望み通りの結果が得られました!

rustdoc の画像
rustdoc の画像

まとめ

procedural macro を作るには試行錯誤とネットサーフィンが大事。

[See repo JSON]