Привет, Хабр!
Сегодня поговорим про процедурные макросы как про инструмент разработчика, который заботится о 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
.
Правила, которые окупаются:
Генерируемая функция и любые
use
прячутся внутри безымянного скоупа черезconst _: () = { ... };
Чтобы не насорить в пространстве имён.Внутренние идентификаторы создаём через
format_ident!
и заранее обговариваем префикс.Для ссылок на сторонние зависимости используем абсолютные пути и
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 есть три рабочих инструмента:
syn::Error::new
иsyn::Error::new_spanned
, далее.to_compile_error()
. Это выдаёт привычную ошибку компиляции, привязанную к точному месту.compile_error!
как крайний случай, если хочется сгенерировать лаконичное сообщение, но помните, что span привязать аккуратно сложнее.Библиотеки‑надстройки
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, рекомендуем рассмотреть Подписку — выбираете курсы под свои задачи, экономите на обучении, получаете профессиональный рост. Узнать подробнее