4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rust】 手続きマクロとLLMでDNCLトランスパイラ作ってみた

Last updated at Posted at 2025-02-02

dncl.gif

こちらの記事は Rustマクロ冬期講習アドベントカレンダー 26日目の記事です!

...というのは冗談ですが、1ヶ月ぐらいかけて書いた25日目の記事 Rustマクロ作成チートシート! にあまりいいねがつかなくて虚しいのでもう1記事書くことにしました(承認欲求モンスター)(いやカレンダー投稿遅刻するのが悪いよ...) 1

さて、Rustの手続きマクロは とても面白いおもちゃ 可能性の塊です!制約の多い宣言マクロと異なり、 TokenStream を受け取って TokenStream を吐き出しさえするならば、コンパイル時にも関わらずどんなこともできてしまいます。

OpenAI APIを叩くことだって普通にできるので、マクロ経由でLLMを呼び出し、代わりにプログラムを実装してもらうマクロなんかも書けます!

ALT

@namnium_01

今日から仕事始めで当然気乗りしないのでRustの手続きマクロで遊んでいたら、

うっかり全知全能万能マクロを作ってしまいAIに仕事を奪われ、新年早々失業して職ない

GglNFNXasAA7OyV.png

危うく失業しかけましたがなんとかエンジニアやってます :sweat_smile:

万能マクロ hey_chat_gpt::あとは任せた!();

DNCL処理系を実装してみたくなった

こんな感じで年始は手続きマクロで遊びまくっていたところ、何やら大学入試共通テストで使われているDNCL (Daigaku Nyushi Center Language) のポストが流れてきて反応してしまいました。

ALT

@ster_dust017

共テ情報で「buin」とかいう定義されてない変数出てるけど大丈夫なのこれ?舐めてる?

@namnium_01

for文の初期化式で変数宣言してはいけなくて先に宣言しておかなければならないというの、

逆にC89ぐらいしか知らない…

DNCL
Akibi = [5, 3, 4]
buinsu = 3
tantou = 1
buin を 2 から buinsu まで 1 ずつ増やしながら繰り返す:
│  もし Akibi[buin] < Akibi[tantou] ならば:
└  └ tantou = buin
表示する("次の工芸品の担当は部員", tantou, "です。")

「そういえばDNCLの処理系作ったことなかったな...?Rustの手続きマクロで作れるのでは...?!」

Rustの手続きマクロに不可能はありません(あります)。というわけで、ちょうどよい機会なので、手続きマクロを利用した「DNCL → Rust トランスパイラ」を作ってみることにしました!

この先の内容について、一応明言しておきますが ネタです 。コンパイルタイムに時間のかかる処理(API呼び出し)を挟むべきではないですし、ましてや置換結果がコンパイルごとに異なるようなマクロなんていうのは言語道断です。

あくまでもマクロの可能性を提示しているだけです。プロダクションコードで使おうなどと思わないでください!

フルスクラッチは...諦めた! :innocent: → LLMの力を借りよう!

DNCLの言語仕様は短く、すぐに入手できました。(ただコロコロ変わりがちではあるようです)

しかしこのPDFに書かれたDNCL仕様「5. 共通テスト用プログラム表記の例示」にある説明や、「7 関数」の説明を見た時に、自分はDNCLを甘く見ていたことを悟りました。

最初の説明部分:

(前略) しかしながら、問題文の記述を簡潔にするなどの理由で、この説明文書の記述内容に従わない形式で出題することもあります。

7 関数:

※ 「表示する」関数以外は基本的に問題中に 説明 あり

そう...従来の方法でトランスパイラを実装したのでは、実行できない・あるいは 追加の処理を自分で書かなければならない場面 が出てきそうなのです...!

うーん...DNCLで「お気持ちプログラミング」してぇ...!

...
...
... :astonished: :bulb:

_人人人人人人人人人人人人人人人人人人人人人人人人_
> 生成AI にトランスパイルさせりゃいいじゃん...! <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^ ̄

...というわけで、結局冒頭に挙げた hey_chat_gpt::あとは任せた!(); にて使用した方法をほぼそのまま転用して、DNCLトランスパイラを作りました!

Rust
dncl_trans::dncl!(
    @model = "gpt-4o";

    r#"
# 本プログラムでは配列の添字は 1 から始まります
Akibi = [5, 3, 4]
buinsu = 3
tantou = 1
buin を 2 から buinsu まで 1 ずつ増やしながら繰り返す:
│  もし Akibi[buin] < Akibi[tantou] ならば:
└  └ tantou = buin
表示する("次の工芸品の担当は部員", tantou, "です。")
"#
);
ChatGPTの返答

以下はAPIで取得したChatGPTの回答です。この中のコードブロック部分をトランスパイル結果としてマクロ呼び出し元に返します。


以下は、DNCLのプログラムをRustにトランスパイルした結果です。Rustのコードでは、randクレートなどの追加は必要ありませんが、標準ライブラリの追加機能を利用してプログラムを再現しています。DNCLの配列の添字が1から始まるという注記に注意してトランスパイルしています。

fn main() {
    // Akibi配列を1からのインデックスを考慮して作成 (インデックス0は使用しない)
    let akibi = [0, 5, 3, 4];
    let buinsu = 3;
    let mut tantou = 1;

    // 繰り返し文
    for buin in 2..=buinsu {
        if akibi[buin] < akibi[tantou] {
            tantou = buin;
        }
    }

    // 結果の表示
    println!("次の工芸品の担当は部員{}です。", tantou);
}

トランスパイルのポイント

  1. 配列の添字: DNCLでは配列の添字が1から始まっているため、Rustの配列における0ベースのインデックスに合わせるために0番目の要素を無視する形にしました。
  2. ループと条件分岐: forループとif条件でDNCLの制御構造をそのままRustの構文に当てはめています。
  3. 表示部分: Rustのprintln!マクロを使って、出力フォーマットを実現しています。

このコードを実行することで、指定されたロジックに基づいて結果を表示することができます。


実行結果
$ cargo run -q
次の工芸品の担当は部員2です。

出オチ...?そうだよ!

トランスパイラの実装方法解説

本記事の残りでは hey_chat_gpt::あとは任せた!();DNCLトランスパイラ はどうやって実装されているか、どのような処理手順かを、DNCLトランスパイラの方を題材に解説していこうと思います2

LLMにどんなDNCL仕様を与えたかだけが気になる方はこちらへジャンプ↓

...といっても、文科省が配布しているものをほぼそのままMarkdownで書き直して貼り付けただけです。

大まかな処理順序はこのような感じになっています。

  1. マクロ入力のパース
  2. APIキーの読み込み
  3. 利用可能モデルの取得及びモデルが利用可能かの確認
  4. キャッシュの読み込み -> キャッシュの内容( s とする)を得る
  5. キャッシュ( s )がない場合
    1. OpenAI APIにDNCLの仕様を書いたプロンプトとDNCLソースコードを送信し、ChatGPTにトランスパイルしてもらう
    2. ChatGPTの返答( s とする)をキャッシュに保存する
  6. 返答 s 中からRustコードブロックを抜き出し、 TokenStream へとパースしマクロの返り値とする

キャッシュ処理は効率化のためでオマケ機能...に見えますが、今回は絶対に設ける必要があります。というのも、例えばVSCodeを使用している時、rust-analyzerが動作することと思いますが、rust-analyzerは入力途中でも容赦なくマクロの実行、すなわちトランスパイルを試みます。キャッシュを取っていないと、かなりの頻度でAPIを叩きに行ってしまい、GPTのモデル次第ではじゃぶじゃぶと課金枠を使ってしまいます。というわけで、入力となるDNCLソースコードに変化がないうちはAPIを叩きにいかないよう、キャッシュを設けるのが必須となります。ちなみにキャッシュした上でも編集するたびに叩くので、結局APIを叩きすぎないための仕組みも設けます。

クレートのディレクトリ構造は次のような感じです。

dncl_trans
├── Cargo.lock
├── Cargo.toml
├── src
│   ├── impls
│   │   ├── available_models.rs
│   │   ├── cache.rs
│   │   ├── macro_.rs
│   │   ├── markdown.rs
│   │   ├── mod.rs
│   │   └── query.rs
│   └── lib.rs
└── target
ファイル 役割
lib.rs 手続きマクロ dncl! をここで定義しています。ほぼ定義部のみを書くに留め、詳細実装は impls モジュールにて行っています。
impls/mod.rs 一連の流れをまとめた関数 transpile 及びDNCLの仕様をまとめたプロンプトを定義しています。
impls/cache.rs キャッシュ関連の処理をまとめています。
impls/macro_.rs マクロ入力のパース処理をまとめています。
impls/available_models.rs 利用可能なGPTモデル一覧の取得及びキャッシュへの保存処理をまとめています。(今回は解説を省略します。)
impls/query.rs OpenAI APIを叩く処理をまとめています。
impls/markdown.rs マークダウンで書かれた文字列からコードブロックを抜き出します。

ここからは以下の順番で実装の解説をしていきます。

マクロ部分の用意

まずは Cargo.toml の用意をします。ちなみに、今回は全部一気に列挙していますが、クレートは cargo add を使用して Cargo.toml に加えると最新バージョンとなります。

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

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1.0.93"
quote = "1.0.38"
syn = { version = "2.0.96", features = ["full", "extra-traits"] }
reqwest = { version = "0.12.12", features = ["blocking", "json"] }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
toml = "0.8.19"
dotenvy = "0.15.7"
eyre = "0.6.12"
クレート名 役割
proc-macro2 手続きマクロ三種の神器の一つ。基本的な型を提供
quote 手続きマクロ三種の神器の一つ。出力の加工に使用
syn 手続きマクロ三種の神器の一つ。Rust文法関連を司る。今回は主にマクロ入力のパースに使用
reqwest OpenAI APIを叩くのに使用。マクロで利用するために blocking フィーチャーを指定しています
serde/serde_json APIとやり取りするJSONを扱うために使用
toml キャッシュファイルの保存に使用
dotenvy .env ファイルでAPIキーを設定できるようにするために使用
eyre エラーハンドリングを簡略化するために使用。 ( anyhow の後継)

[lib] proc-macro = true を加えることで、手続きマクロを定義できるクレートとなります。

そして、 src/lib.rs にて dncl! マクロを定義しています。

src/lib.rs
use proc_macro::TokenStream;

mod impls;

/// DNCLプログラムをRustプログラムにトランスパイルするマクロ
///
/// マクロの使用例:
/// ```rust
/// dncl_trans::dncl!(
///     @model = "o1-preview";
///     @max_completion_tokens = 4096;
///     @seed = 123456;
///     @editing = false; // 編集中はtrueにすることでAPIを叩きに行かないようにする
///     // @file = "もしファイル分割しているならこの変数で指定.dncl";
///
///     r#"
///     /* ここにDNCL記法のコードを書く */
///     "#
/// );
/// ```
#[proc_macro]
pub fn dncl(input: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(input as impls::MacroInput);

    impls::dncl_impl(input)
        .unwrap_or_else(syn::Error::into_compile_error)
        .into()
}

#[proc_macro] を付けたパブリックな関数が実質的な手続きマクロのエントリーポイントになります。この関数内に処理を全部書いても良いのですが、いくつかの都合で別な関数に内容を切り出した方がよく、今回も定石的な書き方をしています。

マクロ入力のパース

Rust
let input = syn::parse_macro_input!(input as impls::MacroInput);

ここでマクロの入力をパースしています。 dncl! マクロでは、DNCLソースコードの他、API使用のための設定項目も受け取れるようにしています。これらの設定値に対しては、DNCLソースコードではないことを明確にするため、 @ を先頭に付けるようにしました。

dncl! マクロの使用例
dncl_trans::dncl!(
    @model = "o1-preview";
    @max_completion_tokens = 4096;
    @seed = 123456;
    @editing = false;
    // @file = "もしファイル分割しているならこの変数で指定.dncl";

    r#"
    /* ここにDNCL記法のコードを書く */
    "#
);
設定項目 効果
@model 使用するGPTのモデルを指定。デフォルトは gpt-4oo1-preview などを指定可能
@max_completion_tokens 返答トークンの最大値を調整するために使用。返答が切れてしまった時などにここを調整して長くできる(かも)
@seed シード値。出力が期待したものではなかった時、入力を変化させずに別な出力を試したい時に使用
@editing 編集中かどうかを表すフラグ。 true の間はAPIを叩きに行かなくなる。デフォルトは false
@file 別なファイルにDNCLプログラムを記述したい時に使用。本変数指定時はその後のDNCL入力は読み込まない
r#"..."# DNCLソースコード部分を文字列で指定 ※

※ 今回は一応文字列ではなく生のDNCLコードも行を ; で区切る形で受け取れるようにしてみましたが、Rust的に不適切なトークンが含まれているとエラーになってしまうので、文字列リテラルで受け渡しするのが最も確実となりました。

入力をパースした結果を保存するための構造体をまず用意します。

src/impls/macro_.rs
use proc_macro2::TokenStream;
use syn::LitStr;

pub struct MacroInput {
    pub model: Option<LitStr>,
    pub seed: Option<i64>,
    pub max_completion_tokens: Option<u32>,
    pub editing: bool,
    pub dncl_code: TokenStream,
}

表の内容をそのまま構造体にまとめた感じになります。ただし @file については指定されていたらこの構造体へのパース前にファイルを読みに行くことにし、この段階で内容を dncl_code にまとめてしまうことにします。

( darling クレート 等を用いない場合、) 手続きマクロをパースするには syn::parse::Parse トレイトを実装する形が一番書きやすいです。イメージとしては Debug トレイトや Display トレイトを実装する感じに似ています。

src/impls/macro_.rs
use proc_macro2::TokenStream;
use syn::LitStr;
use syn::parse::{Parse, ParseStream};

pub struct MacroInput {
    pub model: Option<LitStr>,
    pub seed: Option<i64>,
    pub max_completion_tokens: Option<u32>,
    pub editing: bool,
    pub dncl_code: TokenStream,
}

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

todo!() に入力のパース処理を書いていきます!とりあえずまずは、 @xxx = "..."; 部分をパースし、設定値を保存します。

src/impls/macro_.rs (一部)
use std::fs;

use proc_macro2::{Span, TokenStream};
use quote::ToTokens;
use syn::{
    parse::{Parse, ParseStream},
    Token,
};
use syn::{Ident, LitBool, LitInt, LitStr};

// 省略

impl Parse for MacroInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {

        // 設定されていたらSomeになるように設定値変数を定義
        let mut model: Option<LitStr> = None;
        let mut seed: Option<i64> = None;
        let mut max_completion_tokens: Option<u32> = None;
        let mut file_content: Option<String> = None;
        let mut editing = false;

        // `@` で始まる部分をパースしていく
        while input.peek(Token![@]) {
            // `@xxx = "..."` に含まれるトークンを一つずつ消費していく
            input.parse::<Token![@]>()?; // `@` 部分
            let ident = input.parse::<Ident>()?; // `xxx` 部分
            input.parse::<syn::Token![=]>()?; // `=` 部分
            match ident { // `xxx` 部分で分岐
                i if i == "model" => {
                    let lit = input.parse::<LitStr>()?;
                    model = Some(lit);
                }
                /* TODO: 設定項目を増やしていく */
                _ => return Err(syn::Error::new(ident.span(), "unexpected field")),
            }

            // TODO: `;` や `,` を処理する
        }

        todo!()
    }
}

input.parse::<T>() がパース処理のミソになります。 @xxx = "..." の部分について、「 @ か?」「 Ident か?」「 = か?」を順番に見て消費したのち、 xxx 部分で条件分岐( match )し、設定項目種別ごとにパースします。

Rust
match ident {

    // GPTのモデル
    i if i == "model" => {
        // 別な箇所でSpan情報を扱いたいため、Stringではなくてそのまま残す
        let lit = input.parse::<LitStr>()?;
        model = Some(lit);
    }

    // 最大トークン数
    i if i == "max_completion_tokens" => {
        let value = input.parse::<LitInt>()?;
        // base10_parse() でu32取得
        max_completion_tokens = Some(value.base10_parse()?);
    }

    // シード値
    i if i == "seed" => {
        let value = input.parse::<LitInt>()?;
        // base10_parse() でi64取得
        seed = Some(value.base10_parse()?);
    }

    // ファイル
    i if i == "file" => {
        let value = input.parse::<LitStr>()?;
        let file_path = value.value();

        // ファイルを読みに行く
        file_content = Some(fs::read_to_string(file_path)
            // syn::Resultではないエラーをsyn::Resultに変換する。
            // この処理については後述
            .into_syn(value.span())?
        );
    }

    // 編集中かを表すフラグ
    i if i == "editing" => {
        editing = input.parse::<LitBool>()?.value;
    }

    // どれでもない設定値ならエラー
    _ => return Err(syn::Error::new(ident.span(), "unexpected field")),
}

各繰り返しの終わりに ; または , を消費します。

Rust
// `;` , `,` のパース
if input.peek(Token![,]) {
    input.parse::<Token![,]>()?;
} else if input.peek(Token![;]) {
    input.parse::<Token![;]>()?;
}

これで一旦 while ループ部分は完成し、設定項目部分を受け取れるようになりました!

設定項目パース部分まで
src/impls/macro_.rs
use std::fs;

use proc_macro2::{Span, TokenStream};
use quote::ToTokens;
use syn::{
    parse::{Parse, ParseStream},
    Token,
};
use syn::{Ident, LitBool, LitInt, LitStr};

pub struct MacroInput {
    pub model: Option<LitStr>,
    pub seed: Option<i64>,
    pub max_completion_tokens: Option<u32>,
    pub editing: bool,
    pub dncl_code: TokenStream,
}

impl Parse for MacroInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let mut model: Option<LitStr> = None;
        let mut seed: Option<i64> = None;
        let mut max_completion_tokens: Option<u32> = None;
        let mut file_content: Option<String> = None;
        let mut editing = false;

        while input.peek(Token![@]) {
            input.parse::<Token![@]>()?;
            let ident = input.parse::<Ident>()?;
            input.parse::<syn::Token![=]>()?;
            match ident {
                i if i == "model" => {
                    let lit = input.parse::<LitStr>()?;
                    model = Some(lit);
                }
                i if i == "max_completion_tokens" => {
                    let value = input.parse::<LitInt>()?;
                    max_completion_tokens = Some(value.base10_parse()?);
                }
                i if i == "seed" => {
                    let value = input.parse::<LitInt>()?;
                    seed = Some(value.base10_parse()?);
                }
                i if i == "file" => {
                    let value = input.parse::<LitStr>()?;
                    let file_path = value.value();

                    file_content = Some(fs::read_to_string(file_path).into_syn(value.span())?);
                }
                i if i == "editing" => {
                    editing = input.parse::<LitBool>()?.value;
                }
                _ => return Err(syn::Error::new(ident.span(), "unexpected field")),
            }

            // `;` , `,` のパース
            if input.peek(Token![,]) {
                input.parse::<Token![,]>()?;
            } else if input.peek(Token![;]) {
                input.parse::<Token![;]>()?;
            }
        }

        todo!()
    }
}

後はDNCLのソースコード部分について

  • @file = "xxx.dncl" のようにファイルが指定されていた時
  • 文字列リテラルとしてDNCLコードが与えられた時
  • そのままDNCLコードが渡された時
    • 一応設け、色々試したのですがトークンエラーになりがちだったので不要な機能だったかもしれません。

の3パターンで場合分けをし、 TokenStream 型の値を抜き出して dncl_code に入れれば、マクロ入力のパースは完了です。

src/impls/macro_.rs
use std::fs;

use proc_macro2::{Span, TokenStream};
use quote::ToTokens;
use syn::{
    parse::{Parse, ParseStream},
    Token,
};
use syn::{Ident, LitBool, LitInt, LitStr};

// 省略

impl Parse for MacroInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {

        // 省略

        // ファイル指定
        let dncl_code: TokenStream = if let Some(file_content) = file_content {
            if file_content.is_empty() {
                return Err(syn::Error::new(Span::call_site(), "file is empty"));
            }

            LitStr::new(&file_content, Span::call_site()).into_token_stream()

        // r#"表示する("Hello, world")"# のような入力
        } else if input.peek(LitStr) {
            let lit = input.parse::<LitStr>()?;
            let value = lit.value().replace("\n", ";");

            if value.is_empty() {
                return Err(syn::Error::new(lit.span(), "code is empty"));
            }

            LitStr::new(&value, lit.span()).into_token_stream()

        // 表示する("Hello, world"); のような入力
        } else {
            if input.is_empty() {
                return Err(syn::Error::new(Span::call_site(), "code is empty"));
            }

            input.parse::<TokenStream>()?
        };

        Ok(Self {
            model,
            seed,
            max_completion_tokens,
            editing,
            dncl_code,
        })
    }
}

入力が空だと色々不都合だったのでこの時はエラーとすることにしました。

また、文字列リテラルではなくDNCLコードをそのままマクロに渡した場合にも対応することにしましたが、Rustのトークン処理において改行はただの空白文字として扱われてしまうので(ちゃんと確かめていないけどそのはず...)、この場合は末尾に ; を付けてもらうこととしました。

文字列リテラルで利用
dncl_trans::dncl!(
    r#"
    res = 1 + 1
    表示する("1 + 1 = ", res)
    "#
);
そのまま記述
dncl_trans::dncl!(
    res = 1 + 1;
    表示する("1 + 1 = ", res);
);

そのため文字列リテラルで受け取った方(上の方)は改行を ; に置き換えるようにし、入力としては各行の区切りに ; がある状態で dncl_code にコードが入るようにしました(ChatGPTへのクエリ前に ;\n に戻しています)。Rustマクロで使用できる自作DSLを作成する際は改行を文法に入れないほうが良いかもしれませんね。

最後についでに、 Result<T, E>syn::Result<T> に変換するメソッドを生やす IntoSynRes トレイトを自前で用意して、必要なものはすべてそろいます!

src/impls/macro_.rs
/// `Result<T, E>` -> `syn::Result<T>` に変換するトレイト
///
/// `res.into_syn(span)?;` のような使い方を想定
pub trait IntoSynRes<T> {
    fn into_syn(self, span: Span) -> syn::Result<T>;
}

impl<T, E> IntoSynRes<T> for Result<T, E>
where
    E: std::fmt::Display + std::fmt::Debug,
{
    fn into_syn(self, span: Span) -> syn::Result<T> {
        match self {
            Ok(ok) => Ok(ok),
            Err(err) => Err(syn::Error::new(span, err)),
        }
    }
}

ここまでを組み合わせると、 src/impls/macro_.rs は完成です!

src/impls/macro_.rs 全体
src/impls/macro_.rs
use std::fs;

use proc_macro2::{Span, TokenStream};
use quote::ToTokens;
use syn::{
    parse::{Parse, ParseStream},
    Token,
};
use syn::{Ident, LitBool, LitInt, LitStr};

pub struct MacroInput {
    pub model: Option<LitStr>,
    pub seed: Option<i64>,
    pub max_completion_tokens: Option<u32>,
    pub editing: bool,
    pub dncl_code: TokenStream,
}

impl Parse for MacroInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let mut model: Option<LitStr> = None;
        let mut seed: Option<i64> = None;
        let mut max_completion_tokens: Option<u32> = None;
        let mut file_content: Option<String> = None;
        let mut editing = false;

        while input.peek(Token![@]) {
            input.parse::<Token![@]>()?;
            let ident = input.parse::<Ident>()?;
            input.parse::<syn::Token![=]>()?;
            match ident {
                i if i == "model" => {
                    let lit = input.parse::<LitStr>()?;
                    model = Some(lit);
                }
                i if i == "max_completion_tokens" => {
                    let value = input.parse::<LitInt>()?;
                    max_completion_tokens = Some(value.base10_parse()?);
                }
                i if i == "seed" => {
                    let value = input.parse::<LitInt>()?;
                    seed = Some(value.base10_parse()?);
                }
                i if i == "file" => {
                    let value = input.parse::<LitStr>()?;
                    let file_path = value.value();

                    file_content = Some(fs::read_to_string(file_path).into_syn(value.span())?);
                }
                i if i == "editing" => {
                    editing = input.parse::<LitBool>()?.value;
                }
                _ => return Err(syn::Error::new(ident.span(), "unexpected field")),
            }

            // `;` , `,` のパース
            if input.peek(Token![,]) {
                input.parse::<Token![,]>()?;
            } else if input.peek(Token![;]) {
                input.parse::<Token![;]>()?;
            }
        }

        let dncl_code: TokenStream = if let Some(file_content) = file_content {
            if file_content.is_empty() {
                return Err(syn::Error::new(Span::call_site(), "file is empty"));
            }

            LitStr::new(&file_content, Span::call_site()).into_token_stream()
        } else if input.peek(LitStr) {
            let lit = input.parse::<LitStr>()?;
            let value = lit.value().replace("\n", ";");

            if value.is_empty() {
                return Err(syn::Error::new(lit.span(), "code is empty"));
            }

            LitStr::new(&value, lit.span()).into_token_stream()
        } else {
            if input.is_empty() {
                return Err(syn::Error::new(Span::call_site(), "code is empty"));
            }

            input.parse::<TokenStream>()?
        };

        Ok(Self {
            model,
            seed,
            max_completion_tokens,
            editing,
            dncl_code,
        })
    }
}

/// `Result<T, E>` -> `syn::Result<T>` に変換するトレイト
///
/// `res.into_syn(span)?;` のような使い方を想定
pub trait IntoSynRes<T> {
    fn into_syn(self, span: Span) -> syn::Result<T>;
}

impl<T, E> IntoSynRes<T> for Result<T, E>
where
    E: std::fmt::Display + std::fmt::Debug,
{
    fn into_syn(self, span: Span) -> syn::Result<T> {
        match self {
            Ok(ok) => Ok(ok),
            Err(err) => Err(syn::Error::new(span, err)),
        }
    }
}

ここまで実装することで

Rust
let input = syn::parse_macro_input!(input as impls::MacroInput);

という記述でマクロ入力のパースができるようになりました!

OpenAI APIとのやり取り

ここからは impls::dncl_impl 関数を構成するパーツについて話していき、最後にそれらを組み合わせて impls::dncl_impl 関数ないしは transpile 関数を構成します。

OpenAI APIのLLM ( gpt-4o など ) にクエリを投げる機構を2つ(※)用意します。本マクロのコアとなる部分です。

query.rsavailable_models.rs 。今回は query.rs のみ解説

以前もOpenAI APIを利用するアプリを作った経験があったため、クエリで投げるjsonはその時のものを参考にしてみます!

エンドポイント
https://api.openai.com/v1/chat/completions
投げるクエリ例
{
    "model": "gpt-4o",
    "messages": [{"role": "user", "content": "Say this is a test!"}],
    "seed": 1234567,
    "max_completion_tokens": 4096
}

serde クレートを利用して、上記JSONに従いクエリ用構造体を用意します。

src/impls/query.rs
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum Role {
    User,
    Assistant,
}

#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
struct Message {
    pub role: Role,
    pub content: String,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct RequestBody {
    model: String,
    messages: Vec<Message>,
    seed: i64,
    max_completion_tokens: Option<u32>,
}

クエリをするには次の情報が必要になります。

  • APIキー
  • モデル: gpt-4o なのか o1-preview なのか等。
  • seed: シード値。最大値が i64::MAX なので i64 型としている
  • max_completion_tokens: 返答の最大トークン数。途中で途切れてしまう場合にここの数値を大きくすると途切れなくなる、かも

これらの情報を QuerySetting 構造体としてまとめ、ついでにこの構造体にクエリを行うメソッド query を生やすことにします。 QuerySetting もハッシュ値取得に(というよりは変更検知に)使用するため、 Hash をderiveしています。

src/impls/query.rs
use reqwest::blocking::{Client, RequestBuilder};
use serde_json::Value;

// 省略

#[derive(Hash, Clone)]
pub struct QuerySetting {
    pub api_key: String,
    pub model: String,
    pub seed: i64,
    pub max_completion_tokens: Option<u32>,
}

impl QuerySetting {
    pub fn query(&self, input_messages: &[&str]) -> eyre::Result<String> {
        todo!()
    }
}

input_messages は クエリするJSON中の "messages": [{"role": "user", "content": "Say this is a test!"}] にあたる部分です。

ちなみに今回のアプリでは実質的に以下のように使用することになります。分ける必要はありませんが、入力をわざわざconcatする必要もありません。

"messages": [
    {
        "role": "user",
        "content": "(DNCLの言語仕様)"
    },
    {
        "role": "user",
        "content": "(マクロに渡されたDNCLのコード)"
    },
]

クエリにはヘッダー( RequestBuilder 構造体として保持)とボディ(先ほど用意した RequestBody )が必要です。それぞれ用意していきます。まずはヘッダー。

クエリ及びヘッダー
POST https://api.openai.com/v1/chat/completions HTTP/1.1
Content-Type: application/json
Authorization: Bearer [OpenAI APIキー]

このような構造になっているので、これを構成するメソッドを書きます。

src/impls/query.rs
impl QuerySetting {
    fn header(&self) -> RequestBuilder {
        let api_key_field = format!("Bearer {}", self.api_key);

        Client::new()
            .post("https://api.openai.com/v1/chat/completions")
            .header("Content-Type", "application/json")
            .header("Authorization", api_key_field.as_str())
    }

    // 省略
}

次にボディです。DNCLの仕様とソースコードが組み合わさった messages をクエリできる形に加工するメソッドになります。

src/impls/query.rs
impl QuerySetting {
    // 省略

    fn make_request_body(&self, messages: &[&str]) -> RequestBody {
        // &[&str] -> Vec<Message> へ変換
        let messages = messages
            .iter()
            .map(|s| Message {
                role: Role::User,
                content: s.to_string(),
            })
            .collect();

        // 使う変数を抜き出し
        let Self {
            model,
            seed,
            max_completion_tokens,
            ..
        } = self.clone();

        RequestBody {
            model,
            messages,
            seed,
            max_completion_tokens,
        }
    }

    // 省略
}

この2つのメソッドを利用し、 query メソッドの続きを構成します。

src/impls/query.rs
impl QuerySetting {
    // 省略

    pub fn query(&self, input_messages: &[&str]) -> eyre::Result<String> {
        let body: Value = self
            .header()
            .json(&self.make_request_body(input_messages))
            // ここでクエリ
            .send()?
            // serde_json::Value としてパース
            .json()?;

        // 返答を抜き出す
        match &body["choices"][0]["message"]["content"] {
            Value::String(s) => Ok(s.clone()),
            _ => Ok(format!("[Unexpected response]\n{}", body)),
        }
    }
}

reqwest クレートは普段は非同期( .await )で使われます。しかしマクロでは使えない(というよりは、導入してもどのみちボトルネックとなりそうな)ため、 blocking フィーチャーで導入し .await なしでクエリするようにしています。

body の返り値を match で分岐している箇所については、 Err(..) で返すのではなく [Unexpected response] というテキストとして Ok(..) を返しています。これならばどのみちコンパイルエラーになりますし、どんな返答が返ってきたかがキャッシュに保存されるため、ユーザーが原因を解析しやすくなります。

これでクエリ部分は完成です。全体は次のようになります。

src/impls/query.rs 全体
src/impls/query.rs
use reqwest::blocking::{Client, RequestBuilder};
use serde_json::Value;

#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum Role {
    User,
    Assistant,
}

#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
struct Message {
    pub role: Role,
    pub content: String,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct RequestBody {
    model: String,
    messages: Vec<Message>,
    seed: i64,
    max_completion_tokens: Option<u32>,
}

#[derive(Hash, Clone)]
pub struct QuerySetting {
    pub api_key: String,
    pub model: String,
    pub seed: i64,
    pub max_completion_tokens: Option<u32>,
}

impl QuerySetting {
    fn header(&self) -> RequestBuilder {
        let api_key_field = format!("Bearer {}", self.api_key);

        Client::new()
            .post("https://api.openai.com/v1/chat/completions")
            .header("Content-Type", "application/json")
            .header("Authorization", api_key_field.as_str())
    }

    fn make_request_body(&self, messages: &[&str]) -> RequestBody {
        let messages = messages
            .iter()
            .map(|s| Message {
                role: Role::User,
                content: s.to_string(),
            })
            .collect();

        let Self {
            model,
            seed,
            max_completion_tokens,
            ..
        } = self.clone();

        RequestBody {
            model,
            messages,
            seed,
            max_completion_tokens,
        }
    }

    pub fn query(&self, input_messages: &[&str]) -> eyre::Result<String> {
        let body: Value = self
            .header()
            .json(&self.make_request_body(input_messages))
            .send()?
            .json()?;

        match &body["choices"][0]["message"]["content"] {
            Value::String(s) => Ok(s.clone()),
            _ => Ok(format!("[Unexpected response]\n{}", body)),
        }
    }
}

query は軽くテストできると良さそうですね。こんな感じのテストをこのファイルの下部に用意して、

Rust
#[cfg(test)]
mod test {
    use super::QuerySetting;

    #[test]
    fn test_query() {
        dotenvy::dotenv().ok();

        let setting = QuerySetting {
            api_key: std::env::var("OPENAI_API_KEY").unwrap(),
            model: "gpt-3.5-turbo".to_string(),
            seed: 123456,
            max_completion_tokens: Some(4096),
        };

        let response = setting.query(&["Hello, how are you?"]).unwrap();

        dbg!(response);
    }
}

.env ファイルに環境変数を置いておけば、APIを叩く部分だけテストできます。

.env
OPENAI_API_KEY="APIキーをここに貼り付け"

もう一つ、マクロに与えられたモデル文字列が現在のGPTモデルとして用意されているかを調べる機構( available_models.rs )を用意したのですが、記事が長くなりすぎてしまうので省略することとしました。リンク及びソースコードを折りたたんで掲載しておきます。

available_models.rs (ソースコード)
src/impls/available_models.rs
use crate::impls::macro_::IntoSynRes;
use proc_macro2::Span;
use reqwest::blocking::Client;
use serde_json::Value;
use std::env;
use std::fs;
use std::path::PathBuf;
use syn::LitStr;

const DEFAULT_MODEL: &str = "gpt-4o";

fn get_available_gpt_models(api_key: &str) -> eyre::Result<Vec<String>> {
    let api_key_field = format!("Bearer {}", api_key);

    let body: Value = Client::new()
        .get("https://api.openai.com/v1/models")
        .header("Authorization", api_key_field.as_str())
        .send()?
        .json()?;

    let model_array = match &body["data"] {
        Value::Array(array) => array,
        _ => eyre::bail!("Unexpected response: {:?}", body),
    };

    let model_names = model_array
        .iter()
        .map(|value| match &value["id"] {
            Value::String(s) => Ok(s.clone()),
            _ => eyre::bail!("Unexpected response: {:?}", value),
        })
        .collect::<eyre::Result<Vec<String>>>()?;

    Ok(model_names)
}

#[derive(serde::Serialize, serde::Deserialize)]
struct ModelsCache {
    available_models: Vec<String>,
}

// モデル一覧は基本キャッシュしておき、存在しない場合だけAPIを叩く
fn available_gpt_models(api_key: &str) -> eyre::Result<Vec<String>> {
    let manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
    let cache_dir = format!("{}/gpt_responses", manifest_dir);

    if !fs::exists(&cache_dir)? {
        fs::create_dir_all(&cache_dir)?;
    }

    let cached_models_file_name = PathBuf::from(cache_dir).join("available_models.toml");

    let available_models = match fs::read_to_string(&cached_models_file_name) {
        Ok(content) => {
            let cache: ModelsCache = toml::from_str(&content)?;
            cache.available_models
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            let available_models = get_available_gpt_models(api_key)?;

            let cache = ModelsCache {
                available_models: available_models.clone(),
            };
            // tomlとして保存するために変換
            let cache = toml::to_string(&cache)?;
            // 結果を保存
            fs::write(&cached_models_file_name, cache)?;

            available_models
        }
        Err(e) => return Err(e.into()),
    };

    Ok(available_models)
}

pub fn check_available(api_key: &str, model_lit: Option<LitStr>) -> syn::Result<String> {
    let lit = model_lit.unwrap_or(LitStr::new(DEFAULT_MODEL, Span::call_site()));
    let span = lit.span();
    let model_name = lit.value();

    let available_models = available_gpt_models(api_key).into_syn(span)?;

    if !available_models.contains(&model_name) {
        return Err(syn::Error::new(
            span,
            format!("Model {} is not available", model_name),
        ));
    }

    Ok(model_name)
}

キャッシュ機構

キャッシュファイルは Cargo.toml があるディレクトリ( project とする)に gpt_responses というディレクトリを作り、 cache_ハッシュ値.toml というタイトルでTOMLファイルとして保存することにします。

ハッシュ値は std::collections::hash_map::DefaultHasher を利用して求めています。いくつかの行をまとめて関数にしており、次の2つの場面で使用するため、ジェネリクス H: Hash を利用し使い回すようにしています。

  • dncl_code: &str: 指定されていない時の seed 値用
  • (setting, dncl_code): &(&QuerySetting, &str): ファイル名のハッシュ値を求める用
src/impls/cache.rs
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;

pub fn hash_content<H: Hash>(key: &H) -> i64 {
    let mut hasher = DefaultHasher::new();
    key.hash(&mut hasher);
    (hasher.finish() % (i64::MAX as u64)) as _
}

seed 値の上限が i64::MAX であることより、このハッシュ関数の返り値型自体を i64 としました。

hash_content 関数を利用してファイルパス取得用関数 get_cache_file_path を用意します。

src/impls/cache.rs
use std::env;
use std::fs;
use std::path::PathBuf;

use crate::impls::query::QuerySetting;

fn get_cache_file_path(setting: &QuerySetting, content: &str) -> eyre::Result<PathBuf> {
    // Cargo.tomlが存在するディレクトリを取得
    let manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
    let cache_dir = format!("{}/gpt_responses", manifest_dir);

    // gpt_responses ディレクトリがなければ設ける
    if !fs::exists(&cache_dir)? {
        fs::create_dir_all(&cache_dir)?;
    }

    // cache_ハッシュ値.toml というファイル名でパスを生成
    Ok(PathBuf::from(cache_dir).join(format!("cache_{}.toml", hash_content(&(setting, content)))))
}

これができれば後はキャッシュtoml変換用構造体 CachedContent を設け、

  • キャッシュロード用関数 load_cache
  • キャッシュ保存用関数 save_cache

を作るのみです!

キャッシュ操作には QuerySetting の参照が必ず必要なので、格好をつけて QuerySetting のメソッドということにしてみます!

src/impls/cache.rs
use std::fs;

// toml で保存するために構造体を設ける
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedContent {
    model: String,
    seed: i64,
    max_completion_tokens: Option<u32>,
    response: String,
}

impl QuerySetting {
    // キャッシュ読み込み用メソッド
    pub fn load_cache(&self, content: &str) -> eyre::Result<Option<String>> {
        let cache_file = get_cache_file_path(self, content)?;

        // キャッシュを読み込む
        let response = fs::read_to_string(cache_file);

        match response {
            Ok(response) => {
                // tomlをパース
                let response: CachedContent = toml::from_str(&response)?;
                Ok(Some(response.response.to_string()))
            }
            // 存在しない場合
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    // キャッシュ保存用関数
    pub fn save_cache(&self, content: &str, response: &str) -> eyre::Result<()> {
        let cache_file = get_cache_file_path(self, content)?;

        let QuerySetting {
            model,
            seed,
            max_completion_tokens,
            ..
        } = self;

        // toml用構造体にまとめる
        let contents = CachedContent {
            model: model.to_string(),
            seed: *seed,
            max_completion_tokens: *max_completion_tokens,
            response: response.to_string(),
        };

        // tomlとして保存するために変換
        let contents = toml::to_string(&contents)?;

        // 結果を保存
        fs::write(cache_file, contents)?;

        Ok(())
    }
}

キャッシュ機構全体は次のような感じになりました。

src/impls/cache.rs全体
src/impls/cache.rs
use std::collections::hash_map::DefaultHasher;
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use crate::impls::query::QuerySetting;

#[derive(serde::Serialize, serde::Deserialize)]
struct CachedContent {
    model: String,
    seed: i64,
    max_completion_tokens: Option<u32>,
    response: String,
}

fn get_cache_file_path(setting: &QuerySetting, content: &str) -> eyre::Result<PathBuf> {
    let manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
    let cache_dir = format!("{}/gpt_responses", manifest_dir);

    if !fs::exists(&cache_dir)? {
        fs::create_dir_all(&cache_dir)?;
    }

    Ok(PathBuf::from(cache_dir).join(format!("cache_{}.toml", hash_content(&(setting, content)))))
}

pub fn hash_content<H: Hash>(key: &H) -> i64 {
    let mut hasher = DefaultHasher::new();
    key.hash(&mut hasher);
    (hasher.finish() % (i64::MAX as u64)) as _
}

impl QuerySetting {
    pub fn load_cache(&self, content: &str) -> eyre::Result<Option<String>> {
        let cache_file = get_cache_file_path(self, content)?;

        // キャッシュを読み込む
        let response = fs::read_to_string(cache_file);

        match response {
            Ok(response) => {
                let response: CachedContent = toml::from_str(&response)?;
                Ok(Some(response.response.to_string()))
            }
            // 存在しない場合
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    pub fn save_cache(&self, content: &str, response: &str) -> eyre::Result<()> {
        let cache_file = get_cache_file_path(self, content)?;

        let QuerySetting {
            model,
            seed,
            max_completion_tokens,
            ..
        } = self;

        let contents = CachedContent {
            model: model.to_string(),
            seed: *seed,
            max_completion_tokens: *max_completion_tokens,
            response: response.to_string(),
        };

        // tomlとして保存するために変換
        let contents = toml::to_string(&contents)?;

        // 結果を保存
        fs::write(cache_file, contents)?;

        Ok(())
    }
}

返答の加工と出力

色々試したのですが、「ソースコード以外返答しないでね」と釘を刺したモデルでも、ソースコードを返す時は必ず MarkdownのRustコードブロックで返して きました。そして大体は冗長な解説付きです。というわけで、コードブロックにされてしまうことは諦め、コードブロックからRustコードのみ抜き出す関数を用意することとしました。

この関数だけすぐ思いつかなかったのでなんとなくChatGPTに丸投げで出してみました。

src/impls/markdown.rs
pub fn extract_rust_codes(markdown: &str) -> Vec<String> {
    let mut rust_code_blocks = Vec::new();
    let mut in_rust_block = false;
    let mut current_block = String::new();

    for line in markdown.lines() {
        if line.trim_start().starts_with("```rust") {
            in_rust_block = true;
            current_block.clear();
        } else if line.trim_start().starts_with("```") && in_rust_block {
            in_rust_block = false;
            rust_code_blocks.push(current_block.clone());
        } else if in_rust_block {
            current_block.push_str(line);
            current_block.push('\n');
        }
    }

    rust_code_blocks
}

in_rust_block みたいな状態変数を使ったロジック、提示されればどうということはないのですが自分で考えようと思うと一瞬怯むのはなんででしょうかね...? :thinking:

処理全体をまとめる

材料は大体揃ったので、 src/impls/mod.rsdncl_impl.rs 関数及び transpile 関数としてすべてを統括していきます!

src/impls/mod.rs
use cache::hash_content;
use macro_::IntoSynRes;
use proc_macro2::TokenStream;
use query::QuerySetting;
use syn::spanned::Spanned;

mod available_models;
mod cache;
mod macro_;
mod markdown;
mod query;

use available_models::check_available;
pub use macro_::MacroInput;

pub fn dncl_impl(input: MacroInput) -> syn::Result<TokenStream> {
    let res = transpile(input)?;

    Ok(file_content2token_stream(&res))
}

fn file_content2token_stream(res_code: &str) -> TokenStream {
    let codes = markdown::extract_rust_codes(res_code);

    let res_code = match codes.len() {
        0 => res_code.to_string(),
        _ => codes.join("\n"),
    };

    match res_code.parse() {
        Ok(ok) => ok,
        Err(_) => quote::quote! { compile_error!(#res_code); },
    }
}

// DNCL仕様のプロンプトはよく目立つこの場所に記述しておく
const DNCL_SPEC: &str = r#"[省略]"#;

fn transpile(
    MacroInput {
        model,
        seed,
        max_completion_tokens,
        editing,
        dncl_code,
    }: MacroInput,
) -> syn::Result<String> {
    todo!()
}

マクロとしての実体は dncl_impl 関数で、トランスパイラとしての実体は transpile 関数といったところでしょうか? transpile は、マクロ入力 ( macro_::MacroInput ) を受け取り、実質的にはChatGPTからの返答( String 型)をそのまま返す関数になっています。 file_content2token_stream 関数では、トランスパイラが返した文字列としての結果を、コードブロックからRustコードを抜き出す処理等を経て、マクロの都合に合わせ proc_macro2::TokenStream へと変換しています。

残りは transpile 関数の中身です。ここまでで用意してきた関数をここでまとめます。(ハンズオン的にやるなら transpile 関数を起点に書いた方がわかりやすかったかもしれませんね...)

src/impls/mod.rs
use cache::{hash_content, load_cache, save_cache};
use macro_::IntoSynRes;
use proc_macro2::TokenStream;
use query::QuerySetting;
use syn::spanned::Spanned;
use available_models::check_available;
pub use macro_::MacroInput;

// 省略

// DNCL仕様のプロンプトはよく目立つこの場所に記述しておく
const DNCL_SPEC: &str = r#"[省略]"#;

fn transpile(
    MacroInput {
        model,
        seed,
        max_completion_tokens,
        editing,
        dncl_code,
    }: MacroInput,
) -> syn::Result<String> {
    // 編集中の場合は最小限のコードを返す
    if editing {
        return Ok("fn main() {}".to_string());
    }

    // ソースコード部分の処理
    // エラー用Spanを取り出し文字列化
    let span = dncl_code.span();
    let dncl_code = dncl_code.to_string().replace(";", "\n");
    let dncl_code = format!("```dncl\n{}\n```", dncl_code);

    // APIキー読み込み
    dotenvy::dotenv().ok();
    let api_key = std::env::var("OPENAI_API_KEY").into_syn(span)?;

    // モデルが存在するかチェック
    let model = check_available(&api_key, model)?;

    // なるべく冪等に近づけるために、seedが指定されていない場合はハッシュを指定
    let seed = seed.unwrap_or_else(|| hash_content(&dncl_code));

    let setting = QuerySetting {
        api_key,
        model,
        seed,
        max_completion_tokens,
    };

    // キャッシュがあるならクエリしない
    if let Some(cache) = setting.load_cache(&dncl_code).into_syn(span)? {
        return Ok(cache);
    }

    // トランスパイルクエリ部分
    let response = setting.query(&[DNCL_SPEC, &dncl_code]).into_syn(span)?;

    // 返答をキャッシュへ保存
    setting.save_cache(&dncl_code, &response).into_syn(span)?;

    Ok(response)
}

ここまで丁寧に実装してきたので、コメントされていること以上に解説できることがあまりないです :sweat_smile:

Rust
// ソースコード部分の処理
// エラー用Spanを取り出し文字列化
let span = dncl_code.span();
let dncl_code = dncl_code.to_string().replace(";", "\n");
let dncl_code = format!("```dncl\n{}\n```", dncl_code);

ここで span を取っている理由は解説していないと思います。この spansyn::Error を構築するために必要で、そのため into_syn メソッドに渡されています...とはいえ、 dncl_code に該当する部分に赤線が引かれていたところで情報量はほとんどないかもしれません。

Rust
// APIキー読み込み
dotenvy::dotenv().ok();
let api_key = std::env::var("OPENAI_API_KEY").into_syn(span)?;

続くこの箇所によって、以下のような .env ファイルがあれば OPENAI_API_KEY を環境変数として取得できるようにしています。

.env
OPENAI_API_KEY="APIキーをここに貼り付け"

dotenvy はメンテされていない dotenv のフォーク...なのですが、多少マシなだけでこちらも頻繁にメンテナンスされているわけではなさそうですね...3

Rust
// なるべく冪等に近づけるために、seedが指定されていない場合はハッシュを指定
let seed = seed.unwrap_or_else(|| hash_content(&dncl_code));

あとはここでマクロの結果をなるべく冪等にするためにシード値指定必須という風にしているのですが、あまり意味がない箇所かもしれないです。

というのも、実際に実験してみたらしい記事いわく、同じシード値でも同じ結果になるとは限らないことがあるらしく、あくまでも努力義務的なものらしいのです...

そして悲報、筆者の環境でも同じシード値で違う値が返ってくるのを経験しました...シード値とは... :cry:

ともかくこれで全部完成です、最後に src/impls/mod.rs 全体を掲載します!

src/impls/mod.rs 全体
src/impls/mod.rs
use cache::hash_content;
use macro_::IntoSynRes;
use proc_macro2::TokenStream;
use query::QuerySetting;
use syn::spanned::Spanned;

mod available_models;
mod cache;
mod macro_;
mod markdown;
mod query;

use available_models::check_available;
pub use macro_::MacroInput;

pub fn dncl_impl(input: MacroInput) -> syn::Result<TokenStream> {
    let res = transpile(input)?;

    Ok(file_content2token_stream(&res))
}

fn file_content2token_stream(res_code: &str) -> TokenStream {
    let codes = markdown::extract_rust_codes(res_code);

    let res_code = match codes.len() {
        0 => res_code.to_string(),
        _ => codes.join("\n"),
    };

    match res_code.parse() {
        Ok(ok) => ok,
        Err(_) => quote::quote! { compile_error!(#res_code); },
    }
}

const DNCL_SPEC: &str = r#"[省略]"#;

fn transpile(
    MacroInput {
        model,
        seed,
        max_completion_tokens,
        editing,
        dncl_code,
    }: MacroInput,
) -> syn::Result<String> {
    // 編集中の場合は最小限のコードを返す
    if editing {
        return Ok("fn main() {}".to_string());
    }

    // ソースコード部分の処理
    // エラー用Spanを取り出し文字列化
    let span = dncl_code.span();
    let dncl_code = dncl_code.to_string().replace(";", "\n");
    let dncl_code = format!("```dncl\n{}\n```", dncl_code);

    // APIキー読み込み
    dotenvy::dotenv().ok();
    let api_key = std::env::var("OPENAI_API_KEY").into_syn(span)?;

    // モデルが存在するかチェック
    let model = check_available(&api_key, model)?;

    // なるべく冪等に近づけるために、seedが指定されていない場合はハッシュを指定
    let seed = seed.unwrap_or_else(|| hash_content(&dncl_code));

    let setting = QuerySetting {
        api_key,
        model,
        seed,
        max_completion_tokens,
    };

    // キャッシュがあるならクエリしない
    if let Some(cache) = setting.load_cache(&dncl_code).into_syn(span)? {
        return Ok(cache);
    }

    // トランスパイルクエリ部分
    let response = setting.query(&[DNCL_SPEC, &dncl_code]).into_syn(span)?;

    // 返答をキャッシュへ保存
    setting.save_cache(&dncl_code, &response).into_syn(span)?;

    Ok(response)
}

DNCL仕様プロンプト

[省略]としていた DNCL_SPEC 定数の中身です。ほぼ 試作問題「情報」の概要 に掲載されている内容をMarkdownに直しただけです。

長いので折りたたんでいます。

DNCL_SPEC

崩れないよう、コードブロック部分はバッククォートを3つではなく2つにしています。

大学入試共通テスト用プログラミング言語DNCLの仕様を以下に示します。

---
高等学校の「情報Ⅰ」の授業で使用するプログラミング言語は多様であることから、共通テスト『情報Ⅰ』の試作問題作成にあたり、共通テスト用のプログラム表記を使用します。以下、参考のためにその基本を例示します。しかしながら、問題文の記述を簡潔にするなどの理由で、この説明文書の記述内容に従わない形式で出題することもあります。したがって、共通テスト『情報Ⅰ』の受験に際しては、当該問題文の説明や指示に注意し、それらに沿って解答してください。なお、経過措置問題『旧情報(仮)』についても同様に扱うこととします。

# 1. 変数

通常の変数例: `kosu`, `kingaku_kei`

(変数名は英字で始まる英数字と `_` の並び)

配列変数の例: `Tokuten[3]`, `Data[2, 4]` (配列名は先頭文字が大文字)

※ 特に説明がない場合、配列の要素を指定する添字は `0` から始まる

# 2. 文字列

文字列はダブルクォーテーション `"` で囲む

``dncl
moji = "I'll be back.";
message = "祇園精舎の" + "鐘の声" # `+`で連結できる;
``

コード中に示した通り、 `+`で連結できる

# 3. 代入文

``dncl
kosu = 3, kingaku = 300 # 複数文を1行で表記できる
kingaku_goukei = kingaku * kosu
namae = "Komaba"
Data = [10, 20, 30, 40, 50, 60]
Tokentenのすべての値を0にする
nyuryoku = {外部からの入力}
``

# 4. 算術演算

加減剰余の四則演算は、 `+` 、 `-` 、 `*` 、 `/` で表す
整数の除算では、商(整数)を `÷` または `div` で、余りを `%` で表す
べき乗は `**` で表す

# 5. 比較演算

`==` (等しい)、 `!=` (等しくない)、 `>` 、 `<`、 `>=` 、 `<=`

# 6. 論理演算

`and` (論理積)、 `or` (論理和)、 `not` (否定)

# 7. 関数

## 値を返す関数を使用する例

``dncl
kazu = 要素数(Data)
saikoro = 整数(乱数() * 6) + 1
``

## 値を返さない関数を呼び出す例

``dncl
表示する(Data)
表示する(Kamoku[i], "の特典は", Tensu[i], "です")
``

※ 「表示する」関数はカンマ区切りで文字列や数値を連結できる
※ 「表示する」関数以外は基本的に問題中に説明あり (ない場合は関数名より忖度してください)

# 8. 制御文 (条件分岐)

``dncl
もし x < 3 ならば:
│ x = x + 1
└ y = y + 1
``

``dncl
もし x == 3 ならば:
|  x = x - 1
そうでなければ:
|= y = y * 2
``

``dncl
もし x >= 3 ならば:
|  x = x - 1
そうでなくもし x < 0 ならば:
|  x = x * 2
そうでなければ:
|= y = y * 2
``

※ `│` (または `|` )と `└` (または `|=` )で制御範囲を表し、 `└` (または `|=` ) の行は制御文の終わりの行を示す

# 9. 制御文 (繰り返し)

``dncl
x を 0 から 9 まで 1 ずつ増やしながら繰り返す:
└ goukei = goukei + Data[x]
``

※ `減らしながら` もある

``dncl
n < 10 の間繰り返す:
|  goukei = goukei + n
|= n = n + 1
``

※ `│` (または `|` )と `└` (または `|=` )で制御範囲を表し、 `└` (または `|=` ) の行は制御文の終わりの行を示す

# 10. コメント

``dncl
atai = 乱数() # 0 以上 1 未満のランダムな少数を atai に代入する
``

※ 1行内において # 以降の記述は処理の対象とならない

# 11. 補足

DNCLの仕様ではありませんが、トランスパイルの都合上入力が特殊になっていることがあります。以下の点に注意してください。

- 行の先頭に `(1)` や `(1)` のように行番号があることがありますが、この番号は単に無視してください。
- 余分な改行が入っていることがあります。もし改行が2行連続していてもそれは1つの改行区切りとして扱ってください。
- 空白による区切りが適切でない(多かったりまったくなかったりする)場合がありますが、本仕様において空白区切りによる曖昧さは発生しないはずです。いい感じに解析してください。

---

次にDNCLのプログラムが与えられますので、エントリポイントとなる `main` 関数を含めたRustプログラムへトランスパイルしてください。

以下注意事項です。

- `rand` 等のサードパーティクレートはユーザー側が自分で `Cargo.toml` に追加するため、使用しても構いませんが、不必要なクレートは含めないようにしてください。
- あなたの出力はMarkdownのRustコードブロックからすべて抜き出します。そのため、エントリーポイント( `main` )外に存在してはいけないコード( `let` 文や式など、いわゆる、 `syn::Item` ではないRust構文要素)があると、コンパイルエラーになってしまいます。トランスパイラとしての出力以外ではRustコードブロックではなく何も指定なしのコードブロックを使用してください。

まとめ・所感

以上、DNCLコードをRustコードへトランスパイルする dncl_trans::dncl! マクロの作成方法でした!

思いっきりLLMにおんぶに抱っこになる手法の説明に終始しましたが、

  • 手続きマクロの基本
  • reqwest でAPIを叩く基本

の解説はよくできたのではないかなと思います。

筆者の妄想ではありますが、

  • 大量のソースコード生成をものともしないLLM
  • できるだけ少ない記述で処理を実現しようとするマクロ

LLMとマクロはこれらの性質より強いシナジーを持っておりさらなる可能性を秘めているんじゃないかと思います。

そして、DeepSeekが登場し ローカルLLMも簡単に用意できる 時代になった今、オフラインで使用できるトランスパイラまでもがLLMを組み込むことで簡単に作れてしまえそうです。もう律儀にバッカス・ナウア記法と向き合わなくても、(「使える道具はなんでも使う」というスタンスにいるならば) プログラミング言語は(LLVMではなく)LLMで作れる 、ということなのかもしれません。

...なんて言ってはみましたが今のところどれも戯言です!

こんな副作用マシマシの行儀悪いマクロを書く/使うぐらいならCursorを使いましょう

Rustのマクロの方に可能性を感じてくれた方は Rustマクロ作成チートシート! の方もぜひ活用していただけると幸いです。ここまで読んでいただきありがとうございました!

  1. この記事を書き始めたらこの記事が書き終わる前にいいねが付き始めて現在複雑な気持ちです...それとネガティブな予想だけどこの記事は全然読まれない

  2. 本当はもう少しハンズオン的に書きたかったのですが、作ったプログラムを頭から解説する感じの記事になってしまいました...アドカレの影響で今もうあんまり記事を書く気力が残っておらず、またそんなに規模の大きくないアプリなので頭から書くことを許してくれという気持ちです :sweat_smile:

  3. もう完成され尽くしていてメンテの必要性がないクレートが、古いという理由だけで推奨されないというのもおかしな話だとは思います。多分dotenvが非推奨なのは他にも理由があるのかも

4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?