Skip to main content

backbeat_macros/
lib.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! `#[derive(Event)]` for backbeat.
5//!
6//! Annotate a `#[repr(C)]` struct to make it a traceable event. The derive reflects the struct's
7//! fields into a `const` [`EventSchema`] (offsets via [`core::mem::offset_of!`], widths via
8//! [`core::mem::size_of`]), computes a content-addressed [`EventId`] by hashing that whole schema,
9//! and implements [`backbeat::Event`] — whose `zerocopy::IntoBytes` bound makes recording a single
10//! memcpy and rejects padded layouts the reader can't describe.
11//!
12//! ```ignore
13//! use backbeat::{Event, EventEnum};
14//! use backbeat::zerocopy::{Immutable, IntoBytes};
15//!
16//! /// Which way a frame is going.
17//! #[derive(EventEnum, IntoBytes, Immutable, Clone, Copy)]
18//! #[repr(u8)]
19//! enum Direction { Incoming = 0, Outgoing = 1 }
20//!
21//! /// A frame was queued for sending.
22//! #[derive(Event, IntoBytes, Immutable)]
23//! #[event(namespace = "my_crate::frame")]
24//! #[repr(C)]
25//! struct QueueData {
26//!     #[event(key)]            packet_number: u64,
27//!     /// Offset into the stream.
28//!     #[event(unit = "bytes")] offset: u64,
29//!     direction: Direction,    // a strongly-typed enum field
30//!     is_fin: bool,
31//! }
32//! ```
33//!
34//! The generated code exposes `QueueData::SCHEMA` (an `EventSchema`), `QueueData::ID` (its
35//! `EventId`), and `QueueData::QUALIFIED_NAME`, and implements `Event`. Because the id hashes the
36//! whole schema, two builds whose layout or field metadata differ get distinct ids and never alias
37//! in a dump's registry.
38//!
39//! Container attributes (on the struct):
40//!
41//! * `#[event(namespace = "…")]` — required; the event's namespace prefix.
42//! * `#[event(span = enter)]` / `#[event(span = exit)]` — mark this event as one half of a span, so
43//!   the trace converter can pair begin/end records into a duration slice. A spanned event must
44//!   carry exactly one `#[event(span_id)]` field.
45//!
46//! Field attributes (mutually-exclusive *roles* — at most one per field):
47//!
48//! * `#[event(key)]` — promote this field to a top-level join/index column in the output table.
49//! * `#[event(span_id)]` — this `u64` is the span's own id (required on a `span = enter|exit`
50//!   event; the enter and exit halves carry the same value so the converter pairs them).
51//! * `#[event(parent_span_id)]` — this `u64` is the enclosing span's id, linking this event under
52//!   its parent. Allowed on any event, including plain (non-span) ones.
53//!
54//! Other field attributes (combine with a role):
55//!
56//! * `#[event(unit = "…")]` — attach a unit hint (`"bytes"`, `"ns"`, …) carried into the output.
57//! * `#[event(sentinel = <const>)]` — declare an in-band "absent" marker for an integer field (e.g.
58//!   `sentinel = u64::MAX` for an unassigned packet number, or `sentinel = 0` for a `dump_id` only
59//!   some events carry). The converter maps a field equal to its sentinel to SQL NULL, so the wire
60//!   format needs no nulls yet `WHERE x IS NULL` works.
61//! * `#[event(interned)]` / `#[event(interned(dynamic))]` — the (`u32`) field is an intern id
62//!   resolved against the dump's intern table; `dynamic` marks runtime-built values.
63//!
64//! Enum-typed fields use a separate `#[derive(EventEnum)]` on the (`#[repr(uN)]`, fieldless) enum;
65//! the field then carries the strong type and the schema records its value→label map automatically.
66//!
67//! Field and struct doc comments (`///`) are lifted verbatim into the schema's `description`
68//! fields, so the embedded registry documents itself.
69
70use proc_macro::TokenStream;
71use proc_macro2::TokenStream as TokenStream2;
72use quote::quote;
73use syn::{parse_macro_input, Data, DeriveInput, Expr, ExprLit, Fields, Lit, LitStr, Type};
74
75/// A field's role, mirroring `backbeat::schema::FieldRole` on the macro side.
76#[derive(Clone, Copy, PartialEq, Eq)]
77enum Role {
78    Key,
79    SpanId,
80    ParentSpanId,
81}
82
83impl Role {
84    /// The `backbeat::schema::FieldRole` variant tokens this role emits.
85    fn tokens(self) -> TokenStream2 {
86        match self {
87            Role::Key => quote! { ::backbeat::schema::FieldRole::Key },
88            Role::SpanId => quote! { ::backbeat::schema::FieldRole::SpanId },
89            Role::ParentSpanId => quote! { ::backbeat::schema::FieldRole::ParentSpanId },
90        }
91    }
92}
93
94/// A struct's span phase, mirroring `backbeat::schema::Phase`.
95#[derive(Clone, Copy, PartialEq, Eq)]
96enum Phase {
97    None,
98    Enter,
99    Exit,
100}
101
102impl Phase {
103    fn tokens(self) -> TokenStream2 {
104        match self {
105            Phase::None => quote! { ::backbeat::schema::Phase::None },
106            Phase::Enter => quote! { ::backbeat::schema::Phase::Enter },
107            Phase::Exit => quote! { ::backbeat::schema::Phase::Exit },
108        }
109    }
110}
111
112/// Derives `Event` for a struct: its compile-time `EventId`, a reflected `EventSchema`, and an
113/// `impl Event`.
114#[proc_macro_derive(Event, attributes(event))]
115pub fn derive_event(input: TokenStream) -> TokenStream {
116    let input = parse_macro_input!(input as DeriveInput);
117    match expand(input) {
118        Ok(ts) => ts.into(),
119        Err(e) => e.to_compile_error().into(),
120    }
121}
122
123/// Derives `EventEnum` for a fieldless `#[repr(u8|u16|u32|u64)]` enum, so it can be a strongly-typed
124/// event field. Emits the variant→label map and the repr width.
125#[proc_macro_derive(EventEnum)]
126pub fn derive_event_enum(input: TokenStream) -> TokenStream {
127    let input = parse_macro_input!(input as DeriveInput);
128    match expand_event_enum(input) {
129        Ok(ts) => ts.into(),
130        Err(e) => e.to_compile_error().into(),
131    }
132}
133
134fn expand_event_enum(input: DeriveInput) -> syn::Result<TokenStream2> {
135    let name = &input.ident;
136
137    let data = match &input.data {
138        Data::Enum(e) => e,
139        _ => {
140            return Err(syn::Error::new_spanned(
141                &input,
142                "`#[derive(EventEnum)]` can only be applied to an enum",
143            ))
144        }
145    };
146
147    // The discriminant repr width must be an explicit `#[repr(u8|u16|u32|u64)]` — the recorder
148    // stores the enum inline at that width, and the reader needs it to read the discriminant.
149    let repr = enum_repr_width(&input)?;
150
151    // Each variant must be fieldless (it sits inline as a bare discriminant) and contributes a
152    // value→label pair. An explicit discriminant sets the value; otherwise it follows C rules
153    // (previous + 1, starting at 0). We require explicit discriminants so the on-disk value is
154    // never silently shifted by reordering — the value is part of the event's identity.
155    let mut labels = Vec::with_capacity(data.variants.len());
156    for variant in &data.variants {
157        if !matches!(variant.fields, Fields::Unit) {
158            return Err(syn::Error::new_spanned(
159                variant,
160                "`#[derive(EventEnum)]` requires fieldless variants",
161            ));
162        }
163        let label = variant.ident.to_string();
164        let value =
165            match &variant.discriminant {
166                Some((_, expr)) => expr.clone(),
167                None => return Err(syn::Error::new_spanned(
168                    variant,
169                    "`#[derive(EventEnum)]` requires an explicit discriminant, e.g. `Variant = 0` \
170                     (the value is part of the event's on-disk identity)",
171                )),
172            };
173        labels.push(quote! {
174            ::backbeat::schema::EnumLabel { value: (#value) as u64, label: #label }
175        });
176    }
177
178    Ok(quote! {
179        impl ::backbeat::EventEnum for #name {
180            const REPR: u8 = #repr;
181            const LABELS: &'static [::backbeat::schema::EnumLabel] = &[ #(#labels),* ];
182        }
183    })
184}
185
186/// Reads the discriminant byte-width from a `#[repr(uN)]` attribute on an enum.
187fn enum_repr_width(input: &DeriveInput) -> syn::Result<u8> {
188    for attr in &input.attrs {
189        if !attr.path().is_ident("repr") {
190            continue;
191        }
192        let mut width = None;
193        attr.parse_nested_meta(|meta| {
194            width = meta
195                .path
196                .get_ident()
197                .and_then(|i| match i.to_string().as_str() {
198                    "u8" => Some(1),
199                    "u16" => Some(2),
200                    "u32" => Some(4),
201                    "u64" => Some(8),
202                    _ => None,
203                });
204            Ok(())
205        })?;
206        if let Some(w) = width {
207            return Ok(w);
208        }
209    }
210    Err(syn::Error::new_spanned(
211        input,
212        "`#[derive(EventEnum)]` requires an explicit `#[repr(u8|u16|u32|u64)]`",
213    ))
214}
215
216fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
217    let name = &input.ident;
218
219    // Container attributes: namespace (required) and span phase (optional).
220    let container = ContainerAttrs::parse(&input)?;
221
222    // `#[derive(Event)]` describes a fixed C layout; anything else has no stable offsets to reflect.
223    let fields =
224        match &input.data {
225            Data::Struct(s) => match &s.fields {
226                Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
227                // No fields is legal only for a plain marker event — a span needs a `span_id` field.
228                Fields::Unit => Vec::new(),
229                Fields::Unnamed(_) => return Err(syn::Error::new_spanned(
230                    &s.fields,
231                    "`#[derive(Event)]` requires named fields (tuple structs have no field names \
232                     to use as column names)",
233                )),
234            },
235            _ => {
236                return Err(syn::Error::new_spanned(
237                    &input,
238                    "`#[derive(Event)]` can only be applied to a struct",
239                ))
240            }
241        };
242
243    let description = doc_of(&input.attrs);
244
245    let mut field_defs = Vec::with_capacity(fields.len());
246    // Track span-role fields for cross-field validation after the loop.
247    let mut span_id_fields = 0usize;
248    let mut parent_span_fields = 0usize;
249    for field in &fields {
250        let ident = field.ident.as_ref().expect("named field");
251        let fname = ident.to_string();
252        let fdesc = doc_of(&field.attrs);
253        let attrs = FieldAttrs::parse(&field.attrs)?;
254
255        // A span-id / parent-span-id field must be a bare `u64`: the converter compares ids for
256        // equality across the enter and exit structs, so a uniform width avoids cross-struct
257        // mismatch, and these roles are meaningless on non-integer/narrower types.
258        if matches!(attrs.role, Some(Role::SpanId | Role::ParentSpanId)) {
259            require_u64(&field.ty, attrs.role.unwrap())?;
260        }
261
262        // `sentinel` is an integer "absent" marker, mapped to NULL by comparing the raw integer
263        // image. It is meaningless on a `bool`, a byte array, or an interned (string) field — reject
264        // the cases the macro can see syntactically so a mistake is a clear compile error rather
265        // than silently nulling valid rows (e.g. a `sentinel = 1` on a `bool` nulling every `true`).
266        if attrs.sentinel.is_some() {
267            if attrs.interned.is_some() {
268                return Err(syn::Error::new_spanned(
269                    field,
270                    "`#[event(sentinel = …)]` is not valid on an interned field (it marks an \
271                     absent *integer*, compared against the raw value)",
272                ));
273            }
274            if is_bool(&field.ty) || is_byte_array(&field.ty) {
275                return Err(syn::Error::new_spanned(
276                    &field.ty,
277                    "`#[event(sentinel = …)]` is only valid on an integer field (the sentinel is \
278                     an in-band absent marker compared against the raw integer value)",
279                ));
280            }
281        }
282        match attrs.role {
283            Some(Role::SpanId) => span_id_fields += 1,
284            Some(Role::ParentSpanId) => parent_span_fields += 1,
285            _ => {}
286        }
287
288        let fty = &field.ty;
289
290        // Resolve the field's `FieldType` and labels. An `#[event(interned)]` field is a `u32`
291        // intern id (attribute-driven). Everything else goes through the `FieldTy` trait, resolved
292        // at const-eval: primitives, `[u8; N]`, and any `#[derive(EventEnum)]` type all implement
293        // it, so the macro doesn't need to know the field's concrete type — including enums, whose
294        // variants it cannot see.
295        let (ty_expr, labels_expr) = if let Some(dynamic) = attrs.interned {
296            (
297                quote! { ::backbeat::schema::FieldType::Interned { dynamic: #dynamic } },
298                quote! { &[] },
299            )
300        } else {
301            (
302                quote! { <#fty as ::backbeat::FieldTy>::FIELD_TYPE },
303                quote! { <#fty as ::backbeat::FieldTy>::LABELS },
304            )
305        };
306
307        let desc_expr = opt_str(fdesc.as_deref());
308        let unit_expr = opt_str(attrs.unit.as_deref());
309        let role_expr = match attrs.role {
310            Some(r) => r.tokens(),
311            None => quote! { ::backbeat::schema::FieldRole::None },
312        };
313        // Emit the sentinel as the zero-extended image of the field's stored bytes, which is what
314        // the converter reconstructs with a width-bounded `read_uint`. We (a) cast through the
315        // field's own type so the literal is range-checked and signed values are well-defined, then
316        // (b) widen to `u64` and mask to the field width to STRIP the sign extension that an
317        // `iN as u64` performs. So `#[event(sentinel = -1)] code: i32` yields
318        // `((-1i32) as u64) & 0xFFFF_FFFF` = `0x0000_0000_FFFF_FFFF` — the zero-extension of the 4
319        // on-disk bytes. A full-width `u64::MAX`/`0` masks with `u64::MAX` (the `>= 8` guard avoids
320        // a `1 << 64` overflow). All in `const` context.
321        let sentinel_expr = match &attrs.sentinel {
322            Some(expr) => quote! {
323                ::core::option::Option::Some({
324                    let w = ::core::mem::size_of::<#fty>();
325                    let mask = if w >= 8 { u64::MAX } else { (1u64 << (8 * w)) - 1 };
326                    (((#expr) as #fty) as u64) & mask
327                })
328            },
329            None => quote! { ::core::option::Option::None },
330        };
331
332        field_defs.push(quote! {
333            ::backbeat::schema::FieldSchema {
334                name: #fname,
335                description: #desc_expr,
336                ty: #ty_expr,
337                offset: ::backbeat::schema::layout_u16(::core::mem::offset_of!(#name, #ident)),
338                width: ::backbeat::schema::layout_u16(::core::mem::size_of::<#fty>()),
339                role: #role_expr,
340                unit: #unit_expr,
341                sentinel: #sentinel_expr,
342                enum_labels: #labels_expr,
343            }
344        });
345    }
346
347    // Cross-field span validation.
348    if span_id_fields > 1 {
349        return Err(syn::Error::new_spanned(
350            &input,
351            "an event may declare at most one `#[event(span_id)]` field",
352        ));
353    }
354    if parent_span_fields > 1 {
355        return Err(syn::Error::new_spanned(
356            &input,
357            "an event may declare at most one `#[event(parent_span_id)]` field",
358        ));
359    }
360    match container.phase {
361        // A spanned event must carry exactly one span id (the value enter/exit are paired by).
362        Phase::Enter | Phase::Exit if span_id_fields == 0 => {
363            return Err(syn::Error::new_spanned(
364                &input,
365                "`#[event(span = enter|exit)]` requires exactly one `#[event(span_id)]` field",
366            ));
367        }
368        // A span id only has meaning on an enter/exit event; a plain event associates with a span
369        // via `parent_span_id` instead.
370        Phase::None if span_id_fields > 0 => {
371            return Err(syn::Error::new_spanned(
372                &input,
373                "`#[event(span_id)]` requires the event to be a span \
374                 (`#[event(span = enter)]` or `#[event(span = exit)]`)",
375            ));
376        }
377        _ => {}
378    }
379
380    Ok(emit(name, &container, description, field_defs))
381}
382
383/// Emits the inherent consts (`ID`, `QUALIFIED_NAME`, `SCHEMA`) and the `Event` impl.
384fn emit(
385    name: &syn::Ident,
386    container: &ContainerAttrs,
387    description: Option<String>,
388    field_defs: Vec<TokenStream2>,
389) -> TokenStream2 {
390    let qualified = format!("{}::{name}", container.namespace);
391    let desc_expr = opt_str(description.as_deref());
392    let phase_expr = container.phase.tokens();
393
394    quote! {
395        impl #name {
396            /// Fully-qualified event name, `"namespace::TypeName"`.
397            pub const QUALIFIED_NAME: &'static str = #qualified;
398
399            /// The reflected field layout (also held by [`Self::SCHEMA`]). Named so [`Self::ID`] can
400            /// hash it without referencing `SCHEMA` (which embeds the id — that would be circular).
401            const FIELDS: &'static [::backbeat::schema::FieldSchema] = &[ #(#field_defs),* ];
402
403            /// Content-addressed event id: a hash of the whole schema (name, phase, every field's
404            /// name/type/offset/width/role/unit and any enum labels). Two builds with differing
405            /// layouts get distinct ids and are treated as separate event types sharing a name.
406            pub const ID: ::backbeat::id::EventId =
407                ::backbeat::schema::EventSchema::compute_id(
408                    Self::QUALIFIED_NAME,
409                    #phase_expr,
410                    Self::FIELDS,
411                );
412
413            /// Self-describing layout of this event, reflected from its fields at compile time.
414            pub const SCHEMA: ::backbeat::schema::EventSchema =
415                ::backbeat::schema::EventSchema {
416                    id: Self::ID,
417                    qualified_name: Self::QUALIFIED_NAME,
418                    description: #desc_expr,
419                    record_size: ::backbeat::schema::layout_u16(::core::mem::size_of::<#name>()),
420                    phase: #phase_expr,
421                    fields: Self::FIELDS,
422                };
423        }
424
425        impl ::backbeat::Event for #name {
426            const SCHEMA: ::backbeat::schema::EventSchema = Self::SCHEMA;
427            const ID: ::backbeat::id::EventId = Self::ID;
428            const QUALIFIED_NAME: &'static str = Self::QUALIFIED_NAME;
429        }
430
431        // Register the type so the dumper can self-populate its schema registry. Expands to a
432        // `submit!` under `std` and to nothing on `no_std` (see `backbeat::register_event!`).
433        ::backbeat::register_event!(#name);
434    }
435}
436
437/// Field-level `#[event(...)]` attributes.
438#[derive(Default)]
439struct FieldAttrs {
440    /// The field's role, if any (`key`/`span_id`/`parent_span_id`). At most one — a second is an
441    /// error, so the illegal combinations are unrepresentable.
442    role: Option<Role>,
443    unit: Option<String>,
444    /// `Some(dynamic)` if the field is interned.
445    interned: Option<bool>,
446    /// `Some(expr)` if the field declares a `#[event(sentinel = …)]` "absent" marker. The expression
447    /// is emitted as `(expr) as <field type> as u64` so a typed/signed/narrow constant (`u64::MAX`,
448    /// `0`, `-1` on an `i32`, a named const) widens to the same image the converter reads back.
449    sentinel: Option<Expr>,
450}
451
452impl FieldAttrs {
453    fn parse(attrs: &[syn::Attribute]) -> syn::Result<Self> {
454        let mut out = FieldAttrs::default();
455        for attr in attrs {
456            if !attr.path().is_ident("event") {
457                continue;
458            }
459            attr.parse_nested_meta(|meta| {
460                if meta.path.is_ident("key") {
461                    out.set_role(Role::Key, &meta)?;
462                    Ok(())
463                } else if meta.path.is_ident("span_id") {
464                    out.set_role(Role::SpanId, &meta)?;
465                    Ok(())
466                } else if meta.path.is_ident("parent_span_id") {
467                    out.set_role(Role::ParentSpanId, &meta)?;
468                    Ok(())
469                } else if meta.path.is_ident("unit") {
470                    let lit: LitStr = meta.value()?.parse()?;
471                    out.unit = Some(lit.value());
472                    Ok(())
473                } else if meta.path.is_ident("sentinel") {
474                    // `sentinel = <expr>`: an integer constant the producer uses to mean "absent".
475                    let expr: Expr = meta.value()?.parse()?;
476                    out.sentinel = Some(expr);
477                    Ok(())
478                } else if meta.path.is_ident("interned") {
479                    // `interned` or `interned(dynamic)`.
480                    let mut dynamic = false;
481                    if meta.input.peek(syn::token::Paren) {
482                        meta.parse_nested_meta(|inner| {
483                            if inner.path.is_ident("dynamic") {
484                                dynamic = true;
485                                Ok(())
486                            } else {
487                                Err(inner.error("expected `dynamic`"))
488                            }
489                        })?;
490                    }
491                    out.interned = Some(dynamic);
492                    Ok(())
493                } else {
494                    Err(meta.error("unknown `#[event(...)]` field attribute"))
495                }
496            })?;
497        }
498        Ok(out)
499    }
500
501    /// Sets the field's role, erroring if one was already set (roles are mutually exclusive).
502    fn set_role(&mut self, role: Role, meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
503        if self.role.is_some() {
504            return Err(meta.error(
505                "a field may have at most one role \
506                 (`key` / `span_id` / `parent_span_id` are mutually exclusive)",
507            ));
508        }
509        self.role = Some(role);
510        Ok(())
511    }
512}
513
514/// Container-level (`#[event(...)]` on the struct) attributes: the required namespace and the
515/// optional span phase.
516struct ContainerAttrs {
517    namespace: String,
518    phase: Phase,
519}
520
521impl ContainerAttrs {
522    fn parse(input: &DeriveInput) -> syn::Result<Self> {
523        let mut namespace = None;
524        let mut phase = Phase::None;
525        for attr in &input.attrs {
526            if !attr.path().is_ident("event") {
527                continue;
528            }
529            attr.parse_nested_meta(|meta| {
530                if meta.path.is_ident("namespace") {
531                    let lit: LitStr = meta.value()?.parse()?;
532                    namespace = Some(lit.value());
533                    Ok(())
534                } else if meta.path.is_ident("span") {
535                    // `span = enter` or `span = exit`.
536                    let ident: syn::Ident = meta.value()?.parse()?;
537                    phase = match ident.to_string().as_str() {
538                        "enter" => Phase::Enter,
539                        "exit" => Phase::Exit,
540                        _ => {
541                            return Err(meta.error("`span` must be `enter` or `exit`"));
542                        }
543                    };
544                    Ok(())
545                } else {
546                    Err(meta.error("unknown container-level `#[event(...)]` attribute"))
547                }
548            })?;
549        }
550        let namespace = namespace.ok_or_else(|| {
551            syn::Error::new_spanned(
552                input,
553                "`#[derive(Event)]` requires `#[event(namespace = \"...\")]`",
554            )
555        })?;
556        Ok(Self { namespace, phase })
557    }
558}
559
560/// Whether a field type is the bare `bool` primitive.
561fn is_bool(ty: &Type) -> bool {
562    matches!(ty, Type::Path(p) if p.path.is_ident("bool"))
563}
564
565/// Whether a field type is an array `[_; N]` (e.g. `[u8; 16]` — a `Bytes` field).
566fn is_byte_array(ty: &Type) -> bool {
567    matches!(ty, Type::Array(_))
568}
569
570/// Enforces that a `span_id` / `parent_span_id` field is a bare `u64`.
571fn require_u64(ty: &Type, role: Role) -> syn::Result<()> {
572    let is_u64 = matches!(ty, Type::Path(p) if p.path.is_ident("u64"));
573    if is_u64 {
574        Ok(())
575    } else {
576        let attr = match role {
577            Role::SpanId => "span_id",
578            Role::ParentSpanId => "parent_span_id",
579            Role::Key => unreachable!("require_u64 is only called for span roles"),
580        };
581        Err(syn::Error::new_spanned(
582            ty,
583            format!(
584                "`#[event({attr})]` fields must be `u64` (span ids are compared for equality \
585                 across the enter/exit events, so they need a uniform width)"
586            ),
587        ))
588    }
589}
590
591/// Joins the `///` doc-comment lines on `attrs` into a single trimmed string, or `None` if there
592/// are none. Each `///` line is a `#[doc = "..."]` attribute with a leading space.
593fn doc_of(attrs: &[syn::Attribute]) -> Option<String> {
594    let mut lines = Vec::new();
595    for attr in attrs {
596        if !attr.path().is_ident("doc") {
597            continue;
598        }
599        if let syn::Meta::NameValue(nv) = &attr.meta {
600            if let Expr::Lit(ExprLit {
601                lit: Lit::Str(s), ..
602            }) = &nv.value
603            {
604                lines.push(s.value().trim().to_string());
605            }
606        }
607    }
608    if lines.is_empty() {
609        None
610    } else {
611        Some(lines.join("\n"))
612    }
613}
614
615/// `Some("…")` → `::core::option::Option::Some("…")`, `None` → `::core::option::Option::None`.
616fn opt_str(s: Option<&str>) -> TokenStream2 {
617    match s {
618        Some(s) => quote! { ::core::option::Option::Some(#s) },
619        None => quote! { ::core::option::Option::None },
620    }
621}