LoginSignup
3
0

Rustのマクロで構造体メンバと関数を自動的に実装してみる

Last updated at Posted at 2024-04-25

はじめに

Clapのparse_try_fromメソッドでparseと同じ結果を出したかったけど、うまいこと動かなかったので色々やってたらマクロでコードを自動生成したほうが早いということに気づいた。
とりあえずググって出てきたものをまとめるだけまとめる。
動くのでヨシ!

動機

そもそも一般的なプログラムに複数の引数セットはいらないはずなのでこんなもの無用の長物でしか無い。
ただ、俺自身がshellっぽく動く自己紹介サイトを作っているので、コマンドを実装するときにこういう一つのプログラムの中で複数の引数セットが必要になる状況が発生している。
この状況において、プログラムの引数に対してのハンドリングが煩雑になるとモチベーションがハイパー下がるので、こういう感じの取り組みをしてる。

・・・が、機能拡張のために公式リファレンスとにらめっこしていたら
こんなことをしなくても以下のコードでいい感じにできたのでこの記事はここで終了w

引数なし

main.rs
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct FooCommand {
    args:String,
    args2:Option<String>
}
fn main(){
    let cmd = vec!["command"];
    match FooCommand::try_parse_from(&cmd) {
        Ok(cmd) => {
            /*パースできたときはここ*/
        }
        Err(e) => {
           e.print().unwrap();
           println!("パースエラーが起きたよ!");
        }
    }
}

実行結果

error: the following required arguments were not provided:
  <ARGS>

Usage: command <ARGS> [ARGS2]

For more information, try '--help'.
パースエラーが起きたよ!

-Vオプション(version表示引数)のみ

main.rs
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct FooCommand {
    args:String,
    args2:Option<String>
}
fn main(){
    let cmd = vec!["command"];
    match FooCommand::try_parse_from(&cmd) {
        Ok(cmd) => {
            /*パースできたときはここ*/
        }
        Err(e) => {
           e.print().unwrap();
           println!("パースエラーが起きたよ!");
        }
    }
}

実行結果

test_clap 0.1.0
パースエラーが起きたよ!

-hオプション(help表示引数)のみ

main.rs
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct FooCommand {
    args:String,
    args2:Option<String>
}

fn main() {
    // example
    // $ command -z
    let cmd = vec!["command","-h"];
    match FooCommand::try_parse_from(&cmd) {
        Ok(cmd) => {
            /* The value specified by the argument is correctly parsed and inserted into FooCommand. 
            Proceed with the process using the members of the structure. */
        }
        Err(e) => {
           e.print().unwrap();
           println!("パースエラーが起きたよ!");
        }
    }
}

実行結果

Usage: command <ARGS> [ARGS2]

Arguments:
  <ARGS>
  [ARGS2]

Options:
  -h, --help     Print help
  -V, --version  Print version
パースエラーが起きたよ!

お望みの結果が得られるようになった。
超簡単じゃね?

あ、でも、任意の文字に置き換える機能とかは便利そうだ。

実装に興味の有る方は下へどうぞ。

もうちょっとわかりやすく

こういうコードを

original.rs
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct HogeCommand {
    #[arg(short='V',long)]
    version:bool
    #[arg(short,long)]
    help: bool
}
impl HogeCommand {
    fn try_parse_from_iterator<I, T>(cmd: I) -> Option<Self> where I: IntoIterator<Item = T>, T: Into<OsString> + Clone, {
        if let Ok(cmd) = HogeCommand::try_parse_from(cmd) {
            if cmd.version {
                println!("{}", HogeCommand::command().render_version());
            } else if cmd.help {
                println!("{}", HogeCommand::command().render_help());
            }
            Some(cmd)
        } else {
            println!("{}", HogeCommand::command().render_help());
            None
        }
    }
}


#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct FugaCommand {
    #[arg(short='V',long)]
    version:bool
    #[arg(short,long)]
    help: bool
}
impl FugaCommand {
    fn try_parse_from_iterator<I, T>(cmd: I) -> Option<Self> where I: IntoIterator<Item = T>, T: Into<OsString> + Clone, {
        if let Ok(cmd) = FugaCommand::try_parse_from(cmd) {
            if cmd.version {
                println!("{}", FugaCommand::command().render_version());
            } else if cmd.help {
                println!("{}", FugaCommand::command().render_help());
            }
            Some(cmd)
        } else {
            println!("{}", FugaCommand::command().render_help());
            None
        }
    }
}

こういう感じで書けるようになる。

macro_use.rs
#[cmdline_helper]
#[derive(Parser, Debug,CmdlineHelper)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct HogeCommand {}

#[cmdline_helper]
#[derive(Parser, Debug,CmdlineHelper)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct FugaCommand {}

手続きマクロを実装する。

とりあえずproc-macroなクレートを作る。

Cargo.toml
[package]
name = "cmdline_helper"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
syn = "2.0.60"
proc-macro2 = "1.0.81"
quote = "1.0.36"
clap = {version="4.5.4",features=["derive"]}

[lib]
proc-macro = true
lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse::Parser, parse_macro_input, DeriveInput, ItemStruct};

#[proc_macro_derive(CmdlineHelper)]
pub fn derive_cmd_line_helper(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    dbg!(&input);
    let item = parse_macro_input!(input as ItemStruct);
    let struct_name = item.ident;
    let gen = quote! {
        // 構造体に対してtry_parse_from_iteratorを実装する。
        impl #struct_name {
            fn try_parse_from_iterator<I, T>(cmd: I) -> Option<Self> where I: IntoIterator<Item = T>, T: Into<OsString> + Clone, {
                if let Ok(cmd) = #struct_name::try_parse_from(cmd) {
                    if cmd.version {
                        println!("{}", #struct_name::command().render_version());
                    } else if cmd.help {
                        println!("{}", #struct_name::command().render_help());
                    }
                    Some(cmd)
                } else {
                    println!("{}", #struct_name::command().render_help());
                    None
                }
            }
        }
    };
    gen.into()
}

#[proc_macro_attribute]
pub fn cmdline_helper(_args: TokenStream, input: TokenStream) -> TokenStream {
    let mut ast = parse_macro_input!(input as DeriveInput);
    match &mut ast.data {
        syn::Data::Struct(ref mut struct_data) => {
            match &mut struct_data.fields {
                syn::Fields::Named(fields) => {
                    // 構造体に対してtry_parse_from_iteratorに使用されるメンバ(version/help)を実装する。
                    fields.named.push(
                        syn::Field::parse_named
                            .parse2(quote! { #[arg(short='V',long)] version:bool })
                            .unwrap(),
                    );
                    fields.named.push(
                        syn::Field::parse_named
                            .parse2(quote! { #[arg(short,long)] help: bool})
                            .unwrap(),
                    );
                }
                _ => (),
            }

            return quote! {
                #ast
            }
            .into();
        }
        _ => panic!("`add_field` has to be used with structs "),
    }
}

利用側コード

Cargo.toml
workspace = { members = ["cmdline_helper"] }
[package]
name = "test_clap"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = {version="4.5.4",features=["derive"]}
cmdline_helper={path="./cmdline_helper"}

main.rs
use clap::{CommandFactory, Parser};
use std::ffi::OsString;

use cmdline_helper::*;
// use cmdline_helper::init_cmdline;
#[cmdline_helper]
#[derive(Parser, Debug, CmdlineHelper)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct HogeCommand {}

#[cmdline_helper]
#[derive(Parser, Debug, CmdlineHelper)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct FugaCommand {}

fn main() {
    let cmd = vec!["command", "-V"];
    if let Some(cmd) = HogeCommand::try_parse_from_iterator(&cmd) {}
    if let Some(cmd) = FugaCommand::try_parse_from_iterator(&cmd) {}
}

あとはよしなに、HogeCommandのオプションをClapの引数で定義していけばよしなにいい感じにやってくれる。

こうしたほうが良いなーと思うポイント

lib.rsの中でprintlnマクロ使ってるのが気に食わないのでいい感じに直せばいい感じになりそうなのでいい感じにしようと思う。
おわり。

後日談(いい感じに実装してみた)

パースの失敗かどうかをプログラム上で把握できるようにする

cmdline_helper_typesと言うプロジェクトを新しく作る(proc-macro = trueなクレートだとマクロ以外はエクスポートできないので。)

lib.rs
/// CmdlineResultがMsgのときに使用されます。
/// メッセージが、どのようなきっかけで生成されたかを表します。
pub enum CmdlineMsgHint {
    /// -Vオプションが指定されたことを表します。
    Version,
    /// -hまたは--helpオプションが指定されたことを表します。
    Help,
    /// 返却されるメッセージ文字列はHelpと変わりませんが、パースエラーであることを表します。
    PerseErrorHelp,
    None,
}
pub enum CmdlineResult<T> {
    /// 引数のパースが成功しています。
    Ok(T),
    /// String: ヘルプメッセージまたはバージョンメッセージなど、コンソールに表示されるメッセージを表現しています。
    /// CmdlineMsg: どのような状況でメッセージが表示されようとしているのか、ヒント情報を返します。
    Msg(String, CmdlineMsgHint),
}

cmdline_helperのほう。
今までprintlnしてたところを、CmdlineResultに置き換えただけ。

lib.rs
use std::collections::HashMap;

use proc_macro::{Ident, TokenStream};
use quote::quote;
use syn::{
    parse::{Parse, Parser},
    parse_macro_input, parse_quote, Attribute, DeriveInput, ItemMacro, ItemStruct, PatLit,
};

#[proc_macro_derive(CmdlineHelper)]
pub fn derive_cmd_line_helper(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let item = parse_macro_input!(input as ItemStruct);
    let struct_name = item.ident;

    let gen = quote! {
        // 構造体に対してtry_parse_from_iteratorを実装する。
        impl #struct_name {
            fn try_parse_from_iter<I, T>(cmd: I) -> CmdlineResult<Self> where I: IntoIterator<Item = T>, T: Into<OsString> + Clone, {
                if let Ok(cmd) = #struct_name::try_parse_from(cmd) {
                    if cmd.version {
                        return CmdlineResult::Msg(format!("{}", #struct_name::command().render_version()),CmdlineMsgHint::Version);
                    } else if cmd.help {
                        return CmdlineResult::Msg(format!("{}", #struct_name::command().render_help()),CmdlineMsgHint::Help);
                    }
                    CmdlineResult::Ok(cmd)
                } else {
                    CmdlineResult::Msg(format!("{}", #struct_name::command().render_help()),CmdlineMsgHint::PerseErrorHelp)
                }
            }
        }
    };
    gen.into()
}

任意のショートオプション・ロングオプションに対応する

いろいろな引数に対応したいときに、こういう対応ができると使い勝手に差が出てくるのでサポートしてみる。
例えば、varboseみたいなオプションを-vvvvとか-VVVVみたいにしたいとき、versionの-Vとバッティングしてしまうことが有る。
helpも同様なので、このあたりを任意に指定できるようにしてみる。

lib.rs(derive_cmd_line_helper除外版)
use std::collections::HashMap;

use proc_macro::{Ident, TokenStream};
use quote::quote;
use syn::{
    parse::{Parse, Parser},
    parse_macro_input, parse_quote, Attribute, DeriveInput, ItemMacro, ItemStruct, PatLit,
};

/*本当はderive_cmd_line_helperがここに書いてあるけど、冗長なので省略しました。*/
// pub fn derive_cmd_line_helper(input: proc_macro::TokenStream) -> proc_macro::TokenStream {...}

/// 使い方: オプション引数(短縮オプション)の設定が可能です。
/// ヘルプとバージョン両方の指定 #\[cmdline_helper(help='i',version='v')] 
/// ヘルプのみの指定 #\[ cmdline_helper(help='i')] 
/// ロングオプションの指定(help) #\[ cmdline_helper(long_help="help123")] 
/// ロングオプションの指定(version) #\[ cmdline_helper(long_version="vErsion123")] 
/// 既定値 #\[cmdline_helper]
#[proc_macro_attribute]
pub fn cmdline_helper(args: TokenStream, input: TokenStream) -> TokenStream {
    let mut tokens: HashMap<String, syn::ExprLit> = HashMap::new();
    // ショートオプションとロングオプションのデフォルト値
    tokens.insert("version".to_owned(), parse_quote!('V'));
    tokens.insert("help".to_owned(), parse_quote!('h'));
    tokens.insert("long_version".to_owned(), parse_quote!("version"));
    tokens.insert("long_help".to_owned(), parse_quote!("help"));
    // 属性マクロの引数(cmdline_helper(...))からパースして持ってくる
    let args = parse_macro_input!(args with syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated);
    for arg in args.iter() {
        match &arg.require_name_value().unwrap().value {
            syn::Expr::Lit(token) => {
                tokens.insert(
                    arg.require_name_value()
                        .unwrap()
                        .path
                        .segments
                        .first()
                        .unwrap()
                        .ident
                        .to_string(),
                    token.to_owned(),
                );
            }
            _ => {}
        }
    }
    let mut ast = parse_macro_input!(input as DeriveInput);
    match &mut ast.data {
        syn::Data::Struct(ref mut struct_data) => {
            match &mut struct_data.fields {
                syn::Fields::Named(fields) => {
                    // 構造体に対してtry_parse_from_iteratorに使用されるメンバ(version/help)を実装する。
                    let version = tokens.get("long_version").unwrap();
                    let help = tokens.get("long_help").unwrap();
                    let h = tokens.get("help").unwrap();
                    let v = tokens.get("version").unwrap();
                    fields.named.push(
                        syn::Field::parse_named
                            .parse2(quote! { #[arg(short=#v,long=#version)] version:bool })
                            .unwrap(),
                    );
                    fields.named.push(
                        syn::Field::parse_named
                            .parse2(quote! { #[arg(short=#h,long=#help)] help: bool})
                            .unwrap(),
                    );
                }
                _ => (),
            }

            return quote! {
                #ast
            }
            .into();
        }
        _ => panic!("`add_field` has to be used with structs "),
    }
}

利用者側のコードはこんな感じ

main.rs
use cmdline_helper::*;
#[cmdline_helper(long_help="hoge",long_version="vErsion123",version='z',help='H')]
#[derive(Parser, Debug, CmdlineHelper)]
#[command(author, version, about, long_about = None,disable_help_flag=true,disable_version_flag=true)]
struct HogeCommand {}

fn main() {
    let cmd = vec!["command", "--vErsion123"];
    match HogeCommand::try_parse_from_iter(&cmd) {
        CmdlineResult::Ok(cmd) => {}
        CmdlineResult::Msg(msg, ty) => {
            match ty{
                CmdlineMsgHint::PerseErrorHelp=>{println!("パースエラーが発生しました。");},
                _=>{println!("{}", msg);}
            }
        }
    }
}

実行結果

Usage: test_clap [OPTIONS]

Options:
  -z, --vErsion123
  -H, --hoge

#[cmdline_helper(long_version="vErsion123",help='H')]
こうすると、shortのversionとlongのhelpがそれぞれデフォルト値に設定される。

Usage: test_clap [OPTIONS]

Options:
  -V, --vErsion123
  -H, --help

#[cmdline_helper]
こうすると、すべてがデフォルト値になる。

Usage: test_clap

Options:
  -V, --version
  -h, --help

はい、今度こそ終わり。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0