Привет, Хабр!
Сегодня поговорим про процедурные макросы как про инструмент разработчика, который заботится о 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, рекомендуем рассмотреть Подписку — выбираете курсы под свои задачи, экономите на обучении, получаете профессиональный рост. Узнать подробнее
