Привет, Хабр!

Сегодня поговорим про процедурные макросы как про инструмент разработчика, который заботится о DX.

Процедурные макросы внедряют в исходники произвольный Rust‑код на этапе компиляции. Это часть языка: атрибутные, derive и функциональные макросы работают через proc_macro и получают токены оригинального кода на вход. Формально это расширение языка строго до границ токенов, а не грамматики, что и позволяет Rust менять синтаксис без глобальных поломок макросов.

Процедурные макросы не гигиеничны в полном смысле: их результат ведет себя так, как будто вы написали этот код прямо в месте вызова. Это значит, он влияет на внешние use, сам зависит от окружения и легко цепляет конфликты имён, если не думать заранее. Для этого нам пригодятся Span::call_site и его друзья.

Каркас: минимальный attribute-макрос с нормальной диагностикой

Соберем крейт dx-macros и сразу нацелим его на полезный, backend‑ориентированный сценарий: атрибут #[from_env] на struct Config, который генерит безопасный impl Config { fn from_env() -> Result<Self, Error> } с парсингом переменных окружения, дефолтами и валидацией.

Cargo.toml у крейта с макросами:

[package]
name = "dx-macros"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
proc-macro-crate = "3"

# По желанию для улучшенной диагностики на stable
proc-macro2-diagnostics = "0.10"

[dev-dependencies]
trybuild = "1" # UI-тесты макросов

syn и quote — стандартная связка для парсинга и генерации токенов. proc-macro-crate пригодится, когда сгенерированный код должен ссылаться на «рантаймовую» часть вашего проекта, даже если пользователь переименовал dependency. proc-macro2-diagnostics даёт удобную обёртку над сообщениями на stable, с учётом ограничений. trybuild — реалистичный способ тестировать, что компилятор выдаёт ожидаемые сообщения.

Базовый скелет:

// dx-macros/src/lib.rs
use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{quote, quote_spanned, format_ident};
use syn::{parse_macro_input, AttributeArgs, ItemStruct, spanned::Spanned, Meta, NestedMeta, Lit, Ident};

#[proc_macro_attribute]
pub fn from_env(args: TokenStream, input: TokenStream) -> TokenStream {
    let args = parse_macro_input!(args as AttributeArgs);
    let item = parse_macro_input!(input as ItemStruct);

    match expand_from_env(&args, &item) {
        Ok(ts) => ts.into(),
        Err(diag) => {
            // На stable используем syn::Error + to_compile_error или proc-macro2-diagnostics
            diag.into_compile_error().into()
        }
    }
}

expand_from_env вернет Result<proc_macro2::TokenStream, syn::Error> или совместимый тип, не panic!, не сырой compile_error! без span — это ломает UX, IDE и клиповку ошибок пользователю. Под хорошей диагностикой я понимаю: точный Span на поле или атрибут, человекочитабельный текст, и по возможности help с направляющей. На stable это достигается syn::Error::new_spanned и друзьями. На nightly можно подключить proc_macro::Diagnostic ради детализированных сообщений.

Мини-DSL в атрибутах

Для #[from_env] есть такой DSL:

#[from_env(prefix = "APP_")]
struct Config {
    #[env(name = "PORT", default = 8080, range = "1024..65535")]
    port: u16,

    #[env(name = "DATABASE_URL")]
    database_url: String,

    #[env(name = "LOG_LEVEL", default = "info", one_of = "trace,debug,info,warning,error")]
    log_level: String,
}

Семантика простая: для каждого поля либо явно указываем имя переменной окружения, либо оно получается как prefix + UPPER_SNAKE_CASE(field_ident). Параметры внутри #[env(...)] — дефолт, диапазон, дискретные значения, кастомный парсер по имени функции.

Разберем парсинг:

#[derive(Debug)]
struct MacroCfg {
    prefix: Option<String>,
}

#[derive(Debug)]
struct FieldCfg {
    name: Option<String>,
    default: Option<syn::Expr>, // позволяем строковые и числовые выражения
    range: Option<String>,
    one_of: Option<Vec<String>>,
    parser: Option<syn::Path>, // например my_mod::parse_port
}

fn parse_macro_args(args: &AttributeArgs) -> Result<MacroCfg, syn::Error> {
    let mut prefix = None;

    for arg in args {
        match arg {
            NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("prefix") => {
                if let Lit::Str(s) = &nv.lit {
                    prefix = Some(s.value());
                } else {
                    return Err(syn::Error::new(nv.lit.span(), "ожидается строковый литерал"));
                }
            }
            other => {
                return Err(syn::Error::new(other.span(), "неизвестный аргумент атрибута"));
            }
        }
    }
    Ok(MacroCfg { prefix })
}

fn parse_field_cfg(attrs: &[syn::Attribute]) -> Result<Option<FieldCfg>, syn::Error> {
    for attr in attrs {
        if attr.path().is_ident("env") {
            let meta = attr.parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)?;
            let mut cfg = FieldCfg { name: None, default: None, range: None, one_of: None, parser: None };

            for m in meta {
                match m {
                    Meta::NameValue(nv) if nv.path.is_ident("name") => {
                        if let Lit::Str(s) = nv.lit { cfg.name = Some(s.value()); }
                        else { return Err(syn::Error::new(nv.lit.span(), "ожидается строка")); }
                    }
                    Meta::NameValue(nv) if nv.path.is_ident("default") => {
                        // default может быть любым Expr: "info", 8080, Some(…)
                        cfg.default = Some(syn::Expr::parse.parse2(quote!(#nv.lit))?);
                    }
                    Meta::NameValue(nv) if nv.path.is_ident("range") => {
                        if let Lit::Str(s) = nv.lit { cfg.range = Some(s.value()); }
                        else { return Err(syn::Error::new(nv.lit.span(), "ожидается строка вида \"a..b\"")); }
                    }
                    Meta::NameValue(nv) if nv.path.is_ident("one_of") => {
                        if let Lit::Str(s) = nv.lit {
                            let v = s.value().split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
                            cfg.one_of = Some(v);
                        } else { return Err(syn::Error::new(nv.lit.span(), "ожидается строка со списком")); }
                    }
                    Meta::NameValue(nv) if nv.path.is_ident("parser") => {
                        if let Lit::Str(s) = nv.lit {
                            cfg.parser = Some(syn::parse_str::<syn::Path>(&s.value())?);
                        } else { return Err(syn::Error::new(nv.lit.span(), "ожидается строка с путём к функции")); }
                    }
                    _ => return Err(syn::Error::new(m.span(), "неподдерживаемый ключ в #[env]")),
                }
            }
            return Ok(Some(cfg));
        }
    }
    Ok(None)
}

Почему так, а не тот же ручной разбор TokenTree? Потому что сильная типизация syn заметно уменьшает количество краевых багов: запятые, пробелы, произвольный порядок аргументов, вложенные выражения. Своими руками перебирать TokenTree безопасно только для очень маленьких DSL. Для всего остального используем syn и quote.

Точные Spans и гигиена

Две вещи, которые влияют на UX.

Первая — точный Span. Если проверка какого‑то ключа внутри #[env(...)] не прошла, ошибка должна подсветить конкретный литерал или идентификатор, а не весь атрибут. Это достигается через syn::spanned::Spanned, Error::new_spanned и quote_spanned! для генерации кода с «привязкой» его токенов к месту исходной конструкции. Токены, рожденные внутри quote!, по умолчанию получают Span::call_site; quote_spanned! позволяет применить нужный Span целиком.

Вторая — гигиена имён. Процедурные макросы по дизайну негигиеничны, поэтому любое объявленное имя может столкнуться с именами пользователя. Для управляющих идентификаторов и временных импортов надо использовать либо некрасивые, но уникальные префиксы, либо локализованные скоупы. На stable нет полноценного способа гигиенично генерировать новые имена поэтому используют имена вида __dx_from_env_guard.

Правила, которые окупаются:

  1. Генерируемая функция и любые use прячутся внутри безымянного скоупа через const _: () = { ... }; Чтобы не насорить в пространстве имён.

  2. Внутренние идентификаторы создаём через format_ident! и заранее обговариваем префикс.

  3. Для ссылок на сторонние зависимости используем абсолютные пути и proc-macro-crate::crate_name, чтобы выдержать переименования зависимостей в Cargo.toml.

Фрагмент генерации:

fn expand_from_env(args: &AttributeArgs, item: &ItemStruct) -> Result<proc_macro2::TokenStream, syn::Error> {
    let cfg = parse_macro_args(args)?;
    let struct_ident = &item.ident;
    let fields = match &item.fields {
        syn::Fields::Named(f) => &f.named,
        _ => return Err(syn::Error::new(item.span(), "ожидаются именованные поля")),
    };

    // Собираем шаги построения
    let mut inits = Vec::new();

    for field in fields {
        let field_ident = field.ident.as_ref().unwrap();
        let fc = parse_field_cfg(&field.attrs)?.unwrap_or(FieldCfg {
            name: None, default: None, range: None, one_of: None, parser: None
        });

        let env_name = fc.name.clone().unwrap_or_else(|| {
            let mut s = field_ident.to_string();
            s.make_ascii_uppercase();
            let prefix = cfg.prefix.as_deref().unwrap_or("");
            format!("{}{}", prefix, s)
        });

        // Пример точечной диагностики: range только для числовых типов
        if let Some(range) = fc.range.as_ref() {
            // Простейшая проверка на тип
            let ty = &field.ty;
            let is_numeric = matches!(quote!(#ty).to_string().as_str(), "u8"|"u16"|"u32"|"u64"|"usize"|"i8"|"i16"|"i32"|"i64"|"isize");
            if !is_numeric {
                return Err(syn::Error::new_spanned(&field.ty, "параметр range допустим только для числовых типов"));
            }
            // Можно распарсить "a..b" и встроить проверку на рантайме
        }

        // Генерация инициализации с подсветкой на конкретное поле в случае ошибок
        let span = field.span();
        let field_build = quote_spanned! { span=>
            {
                let key = #env_name;
                let raw = ::std::env::var(key);
                let val = match raw {
                    Ok(s) => s,
                    Err(::std::env::VarError::NotPresent) => {
                        // дефолт
                        #(
                            // подставим default, если задан
                        )*
                        // если дефолт не задан — ошибка времени выполнения с понятным контекстом
                        return ::std::result::Result::Err(::std::format!("переменная окружения {} не задана", key).into());
                    }
                    Err(e) => {
                        return ::std::result::Result::Err(::std::format!("ошибка чтения {}: {}", key, e).into());
                    }
                };
                // Парсинг по умолчанию или через кастомный parser
                let parsed = {
                    #(
                        // если parser указан, вызываем его
                    )*
                    <#ty as ::std::str::FromStr>::from_str(&val)
                        .map_err(|_| ::std::format!("{}: неверный формат для {}", key, ::std::any::type_name::<#ty>()))?
                };
                parsed
            }
        };

        inits.push(quote! { #field_ident: #field_build });
    }

    // Защитный скоуп, чтобы не протекали helper-имена
    let guard = format_ident!("__dx_from_env_guard");
    let expanded = quote! {
        #item

        const #guard: () = {
            impl #struct_ident {
                pub fn from_env() -> ::std::result::Result<Self, ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>> {
                    let res = Self {
                        #(#inits),*
                    };
                    ::std::result::Result::Ok(res)
                }
            }
        };
    };

    Ok(expanded)
}

Все пути делаем абсолютными, чтобы не зависеть от локальных use в модуле пользователя.

quote_spanned! привязывает всю ветку инициализации конкретного поля к field.span(). При ошибке парсинга сообщение будет подсвечивать именно то поле. (

Вспомогательная обёртка const __dx_from_env_guard: () = { ... } дает локальный скоуп.

Про Span и гигиену. Токены, созданные внутри quote!, получают Span::call_site и ведут себя как код, написанный пользователем «снаружи». Это именно то, что нужно для публичных API. Есть ещё Span::mixed_site c «смешанной» гигиеной, полезной в специфических случаях.

Диагностика: stable и nightly варианты, что реально работает

На stable есть три рабочих инструмента:

  1. syn::Error::new и syn::Error::new_spanned, далее .to_compile_error(). Это выдаёт привычную ошибку компиляции, привязанную к точному месту.

  2. compile_error! как крайний случай, если хочется сгенерировать лаконичное сообщение, но помните, что span привязать аккуратно сложнее.

  3. Библиотеки‑надстройки proc-macro2-diagnostics или proc-macro-error, которые помогают сделать сообщения более выразительными и работать с мульти‑span на stable, пусть и с оговорками.

Пример стабильной ошибки на поле:

// где-то в parse_field_cfg
return Err(syn::Error::new_spanned(&nv.lit, "ожидается строка вида \"a..b\""));

Если очень хочется «help» и «note», proc-macro2-diagnostics даёт удобный API:

use proc_macro2_diagnostics::SpanDiagnosticExt;

// ...
return Err(field.span().error("range допустим только для числовых типов")
    .help("уберите `range = \"a..b\"` или поменяйте тип на числовой")
    .into());

У библиотеки есть caveat: на stable все не‑ошибки иногда эмитятся как ошибки.

На nightly можно включить #![feature(proc_macro_diagnostic)] и работать с proc_macro::Diagnostic, указывая уровень, добавляя заметки и «помощь», в том числе мульти‑span. Такой API глубже интегрирован в модель диагностик компилятора и в принципе позволяет делать UX как у rustc.

Пример под nightly:

#![cfg_attr(feature = "nightly", feature(proc_macro_diagnostic))]

#[cfg(feature = "nightly")]
fn emit_range_help(span: proc_macro::Span) {
    use proc_macro::{Diagnostic, Level};
    Diagnostic::spanned(span, Level::Error, "range допустим только для числовых типов")
        .span_help(span, "уберите `range` или поменяйте тип")
        .emit();
}

В документации proc_macro::Diagnostic помечен как ночной и экспериментальный, с набором методов error, warning, note, help и вариантами на span_*. Стабилизация и мульти‑span обсуждались отдельно. То есть быстрые правки формата автоматического fix‑it через стандартный API пока не обещаны — реалистично рассчитывать на «help» и тому подобное

Импорт/путь к «рантайм»-крейту

Классическая проблема: сгенерированный код должен вызвать что‑то из вашего «support»‑крейта. Пользователь мог импортировать его как угодно, имя не гарантируется. Для процедурных макросов аналога $crate нет, поэтому используйте proc-macro-crate::crate_name("your-support"), а затем подставьте либо crate::…, если вы находитесь в нём же, либо реальное имя из Cargo.toml.

Это защищает от сценариев вида:

[dependencies]
your-support = { version = "0.1", package = "my-super-support" }

В макросе корректно получим my_super_support и сгенерируем ::my_super_support::path::to::fn.

Тестируем макрос

Обычные #[test] тесты макросам мало полезны. Нужны проверки, что этот код компилируется, этот падает с таким‑то сообщением, а span указывает на конкретный токен. Для этого есть trybuild. Он запускает rustc на примерах и сравнивает вывод с эталоном. Да, сообщения компилятора могут меняться между версиями, но для публичных API это адекватная цена за DX.

Пример:

// dx-macros/tests/ui.rs
#[test]
fn ui() {
    let t = trybuild::TestCases::new();
    t.pass("tests/ui/ok_basic.rs");
    t.compile_fail("tests/ui/err_range_on_string.rs");
    t.compile_fail("tests/ui/err_missed_env.rs");
}

И tests/ui/err_range_on_string.rs:

use dx_macros::from_env;

#[from_env]
struct Config {
    #[env(range = "1..10")] // ошибка: поле строковое
    name: String,
}

fn main() { let _ = Config::from_env(); }

Эталон tests/ui/err_range_on_string.stderr содержит урезанный вывод ошибки, который должен совпасть.

Подсказки пользователю и почти fix-it

Сделаем кейс: поле log_level c one_of. Если разработчик указал значение, которого нет в списке, подскажем исправления. На stable это будут error и help через proc-macro2-diagnostics. На nightly можно прикрутить детализированные дочерние сообщения с span_help.

fn validate_one_of(span: Span, val: &str, allowed: &[String]) -> Result<(), syn::Error> {
    if allowed.iter().any(|s| s == val) { return Ok(()); }

    #[cfg(feature = "nightly")]
    {
        use proc_macro::{Level, Diagnostic, Span as PMSpan};
        let pm_span: PMSpan = span.unwrap(); // nightly
        let mut diag = Diagnostic::spanned(pm_span, Level::Error, format!("\"{}\" не входит в список допустимых", val));
        let hint = format!("допустимые значения: {}", allowed.join(", "));
        diag = diag.span_help(pm_span, hint);
        diag.emit();
        return Err(syn::Error::new(span, "см. подсказку выше"));
    }

    #[cfg(not(feature = "nightly"))]
    {
        use proc_macro2_diagnostics::SpanDiagnosticExt;
        return Err(span.error(format!("\"{}\" не входит в список допустимых", val))
            .help(format!("допустимые значения: {}", allowed.join(", "))));
    }
}

Полноценные «quick‑fix» с автоматической правкой исходника стандартным API недоступны. Можно приблизиться через чёткие help и понятные предложения, но кнопки исправить в IDE это не даст.

Аккуратная генерация с интеграцией рантайма

Часто удобно вынести общие вспомогалки в support‑крейте, чтобы кодогенерация вызывала что‑то стабильное:

// dx-support/src/lib.rs
pub fn parse_from_str<T: ::std::str::FromStr>(s: &str, key: &str) -> Result<T, String> {
    s.parse::<T>().map_err(|_| format!("{}: неверный формат для {}", key, ::std::any::type_name::<T>()))
}

В макросе:

fn path_to_support() -> syn::Path {
    use proc_macro_crate::{crate_name, FoundCrate};
    let found = crate_name("dx-support").expect("добавьте dependency dx-support");
    match found {
        FoundCrate::Itself => syn::parse_quote!(crate),
        FoundCrate::Name(name) => {
            let ident = Ident::new(&name, Span::call_site());
            syn::parse_quote!(#ident)
        }
    }
}

fn gen_field_init(field: &syn::Field, fc: &FieldCfg, ty: &syn::Type) -> proc_macro2::TokenStream {
    let support = path_to_support();

    let env_name = /* как раньше */;
    let parse_expr = if let Some(parser) = &fc.parser {
        quote! { #parser(&val, key)? }
    } else {
        quote! { #support::parse_from_str::<#ty>(&val, key)? }
    };
    quote! {{
        let key = #env_name;
        let val = match ::std::env::var(key) {
            Ok(s) => s,
            Err(::std::env::VarError::NotPresent) => {
                #(
                    // default
                )*
                return ::std::result::Result::Err(::std::format!("{} не задана", key).into());
            }
            Err(e) => return ::std::result::Result::Err(::std::format!("{}: {}", key, e).into()),
        };
        let parsed: #ty = { #parse_expr };
        parsed
    }}
}

proc-macro-crate спасает нас от переименований.

Часто встречающиеся проблемы и их решения

Проблема: «Я заспанил всё Span::default и ничего не резолвится, даже std». Решение: используйте Span::call_site по умолчанию и quote_spanned! для привязки токенов к месту исходника.

Проблема: «Хочу подсвечивать весь #[env(...)], а не только один ключ». Решение: используйте Error::new(attr.path().span(), "...") или Error::new_spanned(attr, "...").

Проблема: «Мне нужен абсолютный путь к моему рантайм‑крейту, но пользователь его переименовал». Решение: proc-macro-crate::crate_name.

Проблема: «Хочу quick‑fix как в rustc». Реальность: стандартный API для настоящих fix‑it не стабилизирован. На nightly proc_macro::Diagnostic позволяет строить деревья сообщений с help и note.

Проблема: «IDE не разворачивает макрос или падает». Решение: известные расхождения rust‑analyzer с nightly и ABI. Документы и разборы объясняют, почему так бывает и что с этим делали. Для дебага используем -Z proc-macro-backtrace.


Итоги

Хотите хорошую DX:

  • привязывайте диагностические сообщения к правильным Span,

  • будьте аккуратны с гигиеной, используйте call_site и локальные скоупы,

  • не завязывайтесь на имена зависимостей, берите их через proc-macro-crate,

  • на stable используйте syn::Error и proc-macro2-diagnostics, на nightly — proc_macro::Diagnostic,

  • покрывайте всё UI‑тестами (trybuild).

Тогда ваш макрос будет ощущаться как встроенная часть компилятора, а не как чужеродная вставка.

Процедурные макросы хорошо показывают, как в Rust вопросы качества разработки тесно связаны с удобством работы разработчика. Когда инструменты компилятора и дополнительные абстракции помогают писать код безопаснее и понятнее, это напрямую отражается на повседневном опыте.

Если вы хотите глубже разобраться в языке, его экосистеме и научиться применять такие возможности Rust на практике, обратите внимание на курс для начинающих Rust Developer. Basic. На нём системно разбираются основы языка и подходы к работе с ним, чтобы дальнейшее освоение продвинутых инструментов, включая макросы и расширения, стало естественным шагом.

А тем, кто настроен на серьезое развитие в IT, рекомендуем рассмотреть Подписку — выбираете курсы под свои задачи, экономите на обучении, получаете профессиональный рост. Узнать подробнее

Комментарии (0)