backbeat-macros 0.1.2

Derive macro for backbeat: annotate any struct as a traceable event
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
// Copyright (c) 2026 Cameron Bytheway
// SPDX-License-Identifier: MIT

//! `#[derive(Event)]` for backbeat.
//!
//! Annotate a `#[repr(C)]` struct to make it a traceable event. The derive reflects the struct's
//! fields into a `const` [`EventSchema`] (offsets via [`core::mem::offset_of!`], widths via
//! [`core::mem::size_of`]), computes a content-addressed [`EventId`] by hashing that whole schema,
//! and implements [`backbeat::Event`] — whose `zerocopy::IntoBytes` bound makes recording a single
//! memcpy and rejects padded layouts the reader can't describe.
//!
//! ```ignore
//! use backbeat::{Event, EventEnum};
//! use backbeat::zerocopy::{Immutable, IntoBytes};
//!
//! /// Which way a frame is going.
//! #[derive(EventEnum, IntoBytes, Immutable, Clone, Copy)]
//! #[repr(u8)]
//! enum Direction { Incoming = 0, Outgoing = 1 }
//!
//! /// A frame was queued for sending.
//! #[derive(Event, IntoBytes, Immutable)]
//! #[event(namespace = "my_crate::frame")]
//! #[repr(C)]
//! struct QueueData {
//!     #[event(key)]            packet_number: u64,
//!     /// Offset into the stream.
//!     #[event(unit = "bytes")] offset: u64,
//!     direction: Direction,    // a strongly-typed enum field
//!     is_fin: bool,
//! }
//! ```
//!
//! The generated code exposes `QueueData::SCHEMA` (an `EventSchema`), `QueueData::ID` (its
//! `EventId`), and `QueueData::QUALIFIED_NAME`, and implements `Event`. Because the id hashes the
//! whole schema, two builds whose layout or field metadata differ get distinct ids and never alias
//! in a dump's registry.
//!
//! Container attributes (on the struct):
//!
//! * `#[event(namespace = "…")]` — required; the event's namespace prefix.
//! * `#[event(span = enter)]` / `#[event(span = exit)]` — mark this event as one half of a span, so
//!   the trace converter can pair begin/end records into a duration slice. A spanned event must
//!   carry exactly one `#[event(span_id)]` field.
//!
//! Field attributes (mutually-exclusive *roles* — at most one per field):
//!
//! * `#[event(key)]` — promote this field to a top-level join/index column in the output table.
//! * `#[event(span_id)]` — this `u64` is the span's own id (required on a `span = enter|exit`
//!   event; the enter and exit halves carry the same value so the converter pairs them).
//! * `#[event(parent_span_id)]` — this `u64` is the enclosing span's id, linking this event under
//!   its parent. Allowed on any event, including plain (non-span) ones.
//!
//! Other field attributes (combine with a role):
//!
//! * `#[event(unit = "…")]` — attach a unit hint (`"bytes"`, `"ns"`, …) carried into the output.
//! * `#[event(sentinel = <const>)]` — declare an in-band "absent" marker for an integer field (e.g.
//!   `sentinel = u64::MAX` for an unassigned packet number, or `sentinel = 0` for a `dump_id` only
//!   some events carry). The converter maps a field equal to its sentinel to SQL NULL, so the wire
//!   format needs no nulls yet `WHERE x IS NULL` works.
//! * `#[event(interned)]` / `#[event(interned(dynamic))]` — the (`u32`) field is an intern id
//!   resolved against the dump's intern table; `dynamic` marks runtime-built values.
//!
//! Enum-typed fields use a separate `#[derive(EventEnum)]` on the (`#[repr(uN)]`, fieldless) enum;
//! the field then carries the strong type and the schema records its value→label map automatically.
//!
//! Field and struct doc comments (`///`) are lifted verbatim into the schema's `description`
//! fields, so the embedded registry documents itself.

use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Expr, ExprLit, Fields, Lit, LitStr, Type};

/// A field's role, mirroring `backbeat::schema::FieldRole` on the macro side.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Role {
    Key,
    SpanId,
    ParentSpanId,
}

impl Role {
    /// The `backbeat::schema::FieldRole` variant tokens this role emits.
    fn tokens(self) -> TokenStream2 {
        match self {
            Role::Key => quote! { ::backbeat::schema::FieldRole::Key },
            Role::SpanId => quote! { ::backbeat::schema::FieldRole::SpanId },
            Role::ParentSpanId => quote! { ::backbeat::schema::FieldRole::ParentSpanId },
        }
    }
}

/// A struct's span phase, mirroring `backbeat::schema::Phase`.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Phase {
    None,
    Enter,
    Exit,
}

impl Phase {
    fn tokens(self) -> TokenStream2 {
        match self {
            Phase::None => quote! { ::backbeat::schema::Phase::None },
            Phase::Enter => quote! { ::backbeat::schema::Phase::Enter },
            Phase::Exit => quote! { ::backbeat::schema::Phase::Exit },
        }
    }
}

/// Derives `Event` for a struct: its compile-time `EventId`, a reflected `EventSchema`, and an
/// `impl Event`.
#[proc_macro_derive(Event, attributes(event))]
pub fn derive_event(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    match expand(input) {
        Ok(ts) => ts.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

/// Derives `EventEnum` for a fieldless `#[repr(u8|u16|u32|u64)]` enum, so it can be a strongly-typed
/// event field. Emits the variant→label map and the repr width.
#[proc_macro_derive(EventEnum)]
pub fn derive_event_enum(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    match expand_event_enum(input) {
        Ok(ts) => ts.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

fn expand_event_enum(input: DeriveInput) -> syn::Result<TokenStream2> {
    let name = &input.ident;

    let data = match &input.data {
        Data::Enum(e) => e,
        _ => {
            return Err(syn::Error::new_spanned(
                &input,
                "`#[derive(EventEnum)]` can only be applied to an enum",
            ))
        }
    };

    // The discriminant repr width must be an explicit `#[repr(u8|u16|u32|u64)]` — the recorder
    // stores the enum inline at that width, and the reader needs it to read the discriminant.
    let repr = enum_repr_width(&input)?;

    // Each variant must be fieldless (it sits inline as a bare discriminant) and contributes a
    // value→label pair. An explicit discriminant sets the value; otherwise it follows C rules
    // (previous + 1, starting at 0). We require explicit discriminants so the on-disk value is
    // never silently shifted by reordering — the value is part of the event's identity.
    let mut labels = Vec::with_capacity(data.variants.len());
    for variant in &data.variants {
        if !matches!(variant.fields, Fields::Unit) {
            return Err(syn::Error::new_spanned(
                variant,
                "`#[derive(EventEnum)]` requires fieldless variants",
            ));
        }
        let label = variant.ident.to_string();
        let value =
            match &variant.discriminant {
                Some((_, expr)) => expr.clone(),
                None => return Err(syn::Error::new_spanned(
                    variant,
                    "`#[derive(EventEnum)]` requires an explicit discriminant, e.g. `Variant = 0` \
                     (the value is part of the event's on-disk identity)",
                )),
            };
        labels.push(quote! {
            ::backbeat::schema::EnumLabel { value: (#value) as u64, label: #label }
        });
    }

    Ok(quote! {
        impl ::backbeat::EventEnum for #name {
            const REPR: u8 = #repr;
            const LABELS: &'static [::backbeat::schema::EnumLabel] = &[ #(#labels),* ];
        }
    })
}

/// Reads the discriminant byte-width from a `#[repr(uN)]` attribute on an enum.
fn enum_repr_width(input: &DeriveInput) -> syn::Result<u8> {
    for attr in &input.attrs {
        if !attr.path().is_ident("repr") {
            continue;
        }
        let mut width = None;
        attr.parse_nested_meta(|meta| {
            width = meta
                .path
                .get_ident()
                .and_then(|i| match i.to_string().as_str() {
                    "u8" => Some(1),
                    "u16" => Some(2),
                    "u32" => Some(4),
                    "u64" => Some(8),
                    _ => None,
                });
            Ok(())
        })?;
        if let Some(w) = width {
            return Ok(w);
        }
    }
    Err(syn::Error::new_spanned(
        input,
        "`#[derive(EventEnum)]` requires an explicit `#[repr(u8|u16|u32|u64)]`",
    ))
}

fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
    let name = &input.ident;

    // Container attributes: namespace (required) and span phase (optional).
    let container = ContainerAttrs::parse(&input)?;

    // `#[derive(Event)]` describes a fixed C layout; anything else has no stable offsets to reflect.
    let fields =
        match &input.data {
            Data::Struct(s) => match &s.fields {
                Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
                // No fields is legal only for a plain marker event — a span needs a `span_id` field.
                Fields::Unit => Vec::new(),
                Fields::Unnamed(_) => return Err(syn::Error::new_spanned(
                    &s.fields,
                    "`#[derive(Event)]` requires named fields (tuple structs have no field names \
                     to use as column names)",
                )),
            },
            _ => {
                return Err(syn::Error::new_spanned(
                    &input,
                    "`#[derive(Event)]` can only be applied to a struct",
                ))
            }
        };

    let description = doc_of(&input.attrs);

    let mut field_defs = Vec::with_capacity(fields.len());
    // Track span-role fields for cross-field validation after the loop.
    let mut span_id_fields = 0usize;
    let mut parent_span_fields = 0usize;
    for field in &fields {
        let ident = field.ident.as_ref().expect("named field");
        let fname = ident.to_string();
        let fdesc = doc_of(&field.attrs);
        let attrs = FieldAttrs::parse(&field.attrs)?;

        // A span-id / parent-span-id field must be a bare `u64`: the converter compares ids for
        // equality across the enter and exit structs, so a uniform width avoids cross-struct
        // mismatch, and these roles are meaningless on non-integer/narrower types.
        if matches!(attrs.role, Some(Role::SpanId | Role::ParentSpanId)) {
            require_u64(&field.ty, attrs.role.unwrap())?;
        }

        // `sentinel` is an integer "absent" marker, mapped to NULL by comparing the raw integer
        // image. It is meaningless on a `bool`, a byte array, or an interned (string) field — reject
        // the cases the macro can see syntactically so a mistake is a clear compile error rather
        // than silently nulling valid rows (e.g. a `sentinel = 1` on a `bool` nulling every `true`).
        if attrs.sentinel.is_some() {
            if attrs.interned.is_some() {
                return Err(syn::Error::new_spanned(
                    field,
                    "`#[event(sentinel = …)]` is not valid on an interned field (it marks an \
                     absent *integer*, compared against the raw value)",
                ));
            }
            if is_bool(&field.ty) || is_byte_array(&field.ty) {
                return Err(syn::Error::new_spanned(
                    &field.ty,
                    "`#[event(sentinel = …)]` is only valid on an integer field (the sentinel is \
                     an in-band absent marker compared against the raw integer value)",
                ));
            }
        }
        match attrs.role {
            Some(Role::SpanId) => span_id_fields += 1,
            Some(Role::ParentSpanId) => parent_span_fields += 1,
            _ => {}
        }

        let fty = &field.ty;

        // Resolve the field's `FieldType` and labels. An `#[event(interned)]` field is a `u32`
        // intern id (attribute-driven). Everything else goes through the `FieldTy` trait, resolved
        // at const-eval: primitives, `[u8; N]`, and any `#[derive(EventEnum)]` type all implement
        // it, so the macro doesn't need to know the field's concrete type — including enums, whose
        // variants it cannot see.
        let (ty_expr, labels_expr) = if let Some(dynamic) = attrs.interned {
            (
                quote! { ::backbeat::schema::FieldType::Interned { dynamic: #dynamic } },
                quote! { &[] },
            )
        } else {
            (
                quote! { <#fty as ::backbeat::FieldTy>::FIELD_TYPE },
                quote! { <#fty as ::backbeat::FieldTy>::LABELS },
            )
        };

        let desc_expr = opt_str(fdesc.as_deref());
        let unit_expr = opt_str(attrs.unit.as_deref());
        let role_expr = match attrs.role {
            Some(r) => r.tokens(),
            None => quote! { ::backbeat::schema::FieldRole::None },
        };
        // Emit the sentinel as the zero-extended image of the field's stored bytes, which is what
        // the converter reconstructs with a width-bounded `read_uint`. We (a) cast through the
        // field's own type so the literal is range-checked and signed values are well-defined, then
        // (b) widen to `u64` and mask to the field width to STRIP the sign extension that an
        // `iN as u64` performs. So `#[event(sentinel = -1)] code: i32` yields
        // `((-1i32) as u64) & 0xFFFF_FFFF` = `0x0000_0000_FFFF_FFFF` — the zero-extension of the 4
        // on-disk bytes. A full-width `u64::MAX`/`0` masks with `u64::MAX` (the `>= 8` guard avoids
        // a `1 << 64` overflow). All in `const` context.
        let sentinel_expr = match &attrs.sentinel {
            Some(expr) => quote! {
                ::core::option::Option::Some({
                    let w = ::core::mem::size_of::<#fty>();
                    let mask = if w >= 8 { u64::MAX } else { (1u64 << (8 * w)) - 1 };
                    (((#expr) as #fty) as u64) & mask
                })
            },
            None => quote! { ::core::option::Option::None },
        };

        field_defs.push(quote! {
            ::backbeat::schema::FieldSchema {
                name: #fname,
                description: #desc_expr,
                ty: #ty_expr,
                offset: ::backbeat::schema::layout_u16(::core::mem::offset_of!(#name, #ident)),
                width: ::backbeat::schema::layout_u16(::core::mem::size_of::<#fty>()),
                role: #role_expr,
                unit: #unit_expr,
                sentinel: #sentinel_expr,
                enum_labels: #labels_expr,
            }
        });
    }

    // Cross-field span validation.
    if span_id_fields > 1 {
        return Err(syn::Error::new_spanned(
            &input,
            "an event may declare at most one `#[event(span_id)]` field",
        ));
    }
    if parent_span_fields > 1 {
        return Err(syn::Error::new_spanned(
            &input,
            "an event may declare at most one `#[event(parent_span_id)]` field",
        ));
    }
    match container.phase {
        // A spanned event must carry exactly one span id (the value enter/exit are paired by).
        Phase::Enter | Phase::Exit if span_id_fields == 0 => {
            return Err(syn::Error::new_spanned(
                &input,
                "`#[event(span = enter|exit)]` requires exactly one `#[event(span_id)]` field",
            ));
        }
        // A span id only has meaning on an enter/exit event; a plain event associates with a span
        // via `parent_span_id` instead.
        Phase::None if span_id_fields > 0 => {
            return Err(syn::Error::new_spanned(
                &input,
                "`#[event(span_id)]` requires the event to be a span \
                 (`#[event(span = enter)]` or `#[event(span = exit)]`)",
            ));
        }
        _ => {}
    }

    Ok(emit(name, &container, description, field_defs))
}

/// Emits the inherent consts (`ID`, `QUALIFIED_NAME`, `SCHEMA`) and the `Event` impl.
fn emit(
    name: &syn::Ident,
    container: &ContainerAttrs,
    description: Option<String>,
    field_defs: Vec<TokenStream2>,
) -> TokenStream2 {
    let qualified = format!("{}::{name}", container.namespace);
    let desc_expr = opt_str(description.as_deref());
    let phase_expr = container.phase.tokens();

    quote! {
        impl #name {
            /// Fully-qualified event name, `"namespace::TypeName"`.
            pub const QUALIFIED_NAME: &'static str = #qualified;

            /// The reflected field layout (also held by [`Self::SCHEMA`]). Named so [`Self::ID`] can
            /// hash it without referencing `SCHEMA` (which embeds the id — that would be circular).
            const FIELDS: &'static [::backbeat::schema::FieldSchema] = &[ #(#field_defs),* ];

            /// Content-addressed event id: a hash of the whole schema (name, phase, every field's
            /// name/type/offset/width/role/unit and any enum labels). Two builds with differing
            /// layouts get distinct ids and are treated as separate event types sharing a name.
            pub const ID: ::backbeat::id::EventId =
                ::backbeat::schema::EventSchema::compute_id(
                    Self::QUALIFIED_NAME,
                    #phase_expr,
                    Self::FIELDS,
                );

            /// Self-describing layout of this event, reflected from its fields at compile time.
            pub const SCHEMA: ::backbeat::schema::EventSchema =
                ::backbeat::schema::EventSchema {
                    id: Self::ID,
                    qualified_name: Self::QUALIFIED_NAME,
                    description: #desc_expr,
                    record_size: ::backbeat::schema::layout_u16(::core::mem::size_of::<#name>()),
                    phase: #phase_expr,
                    fields: Self::FIELDS,
                };
        }

        impl ::backbeat::Event for #name {
            const SCHEMA: ::backbeat::schema::EventSchema = Self::SCHEMA;
            const ID: ::backbeat::id::EventId = Self::ID;
            const QUALIFIED_NAME: &'static str = Self::QUALIFIED_NAME;
        }

        // Register the type so the dumper can self-populate its schema registry. Expands to a
        // `submit!` under `std` and to nothing on `no_std` (see `backbeat::register_event!`).
        ::backbeat::register_event!(#name);
    }
}

/// Field-level `#[event(...)]` attributes.
#[derive(Default)]
struct FieldAttrs {
    /// The field's role, if any (`key`/`span_id`/`parent_span_id`). At most one — a second is an
    /// error, so the illegal combinations are unrepresentable.
    role: Option<Role>,
    unit: Option<String>,
    /// `Some(dynamic)` if the field is interned.
    interned: Option<bool>,
    /// `Some(expr)` if the field declares a `#[event(sentinel = …)]` "absent" marker. The expression
    /// is emitted as `(expr) as <field type> as u64` so a typed/signed/narrow constant (`u64::MAX`,
    /// `0`, `-1` on an `i32`, a named const) widens to the same image the converter reads back.
    sentinel: Option<Expr>,
}

impl FieldAttrs {
    fn parse(attrs: &[syn::Attribute]) -> syn::Result<Self> {
        let mut out = FieldAttrs::default();
        for attr in attrs {
            if !attr.path().is_ident("event") {
                continue;
            }
            attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("key") {
                    out.set_role(Role::Key, &meta)?;
                    Ok(())
                } else if meta.path.is_ident("span_id") {
                    out.set_role(Role::SpanId, &meta)?;
                    Ok(())
                } else if meta.path.is_ident("parent_span_id") {
                    out.set_role(Role::ParentSpanId, &meta)?;
                    Ok(())
                } else if meta.path.is_ident("unit") {
                    let lit: LitStr = meta.value()?.parse()?;
                    out.unit = Some(lit.value());
                    Ok(())
                } else if meta.path.is_ident("sentinel") {
                    // `sentinel = <expr>`: an integer constant the producer uses to mean "absent".
                    let expr: Expr = meta.value()?.parse()?;
                    out.sentinel = Some(expr);
                    Ok(())
                } else if meta.path.is_ident("interned") {
                    // `interned` or `interned(dynamic)`.
                    let mut dynamic = false;
                    if meta.input.peek(syn::token::Paren) {
                        meta.parse_nested_meta(|inner| {
                            if inner.path.is_ident("dynamic") {
                                dynamic = true;
                                Ok(())
                            } else {
                                Err(inner.error("expected `dynamic`"))
                            }
                        })?;
                    }
                    out.interned = Some(dynamic);
                    Ok(())
                } else {
                    Err(meta.error("unknown `#[event(...)]` field attribute"))
                }
            })?;
        }
        Ok(out)
    }

    /// Sets the field's role, erroring if one was already set (roles are mutually exclusive).
    fn set_role(&mut self, role: Role, meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
        if self.role.is_some() {
            return Err(meta.error(
                "a field may have at most one role \
                 (`key` / `span_id` / `parent_span_id` are mutually exclusive)",
            ));
        }
        self.role = Some(role);
        Ok(())
    }
}

/// Container-level (`#[event(...)]` on the struct) attributes: the required namespace and the
/// optional span phase.
struct ContainerAttrs {
    namespace: String,
    phase: Phase,
}

impl ContainerAttrs {
    fn parse(input: &DeriveInput) -> syn::Result<Self> {
        let mut namespace = None;
        let mut phase = Phase::None;
        for attr in &input.attrs {
            if !attr.path().is_ident("event") {
                continue;
            }
            attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("namespace") {
                    let lit: LitStr = meta.value()?.parse()?;
                    namespace = Some(lit.value());
                    Ok(())
                } else if meta.path.is_ident("span") {
                    // `span = enter` or `span = exit`.
                    let ident: syn::Ident = meta.value()?.parse()?;
                    phase = match ident.to_string().as_str() {
                        "enter" => Phase::Enter,
                        "exit" => Phase::Exit,
                        _ => {
                            return Err(meta.error("`span` must be `enter` or `exit`"));
                        }
                    };
                    Ok(())
                } else {
                    Err(meta.error("unknown container-level `#[event(...)]` attribute"))
                }
            })?;
        }
        let namespace = namespace.ok_or_else(|| {
            syn::Error::new_spanned(
                input,
                "`#[derive(Event)]` requires `#[event(namespace = \"...\")]`",
            )
        })?;
        Ok(Self { namespace, phase })
    }
}

/// Whether a field type is the bare `bool` primitive.
fn is_bool(ty: &Type) -> bool {
    matches!(ty, Type::Path(p) if p.path.is_ident("bool"))
}

/// Whether a field type is an array `[_; N]` (e.g. `[u8; 16]` — a `Bytes` field).
fn is_byte_array(ty: &Type) -> bool {
    matches!(ty, Type::Array(_))
}

/// Enforces that a `span_id` / `parent_span_id` field is a bare `u64`.
fn require_u64(ty: &Type, role: Role) -> syn::Result<()> {
    let is_u64 = matches!(ty, Type::Path(p) if p.path.is_ident("u64"));
    if is_u64 {
        Ok(())
    } else {
        let attr = match role {
            Role::SpanId => "span_id",
            Role::ParentSpanId => "parent_span_id",
            Role::Key => unreachable!("require_u64 is only called for span roles"),
        };
        Err(syn::Error::new_spanned(
            ty,
            format!(
                "`#[event({attr})]` fields must be `u64` (span ids are compared for equality \
                 across the enter/exit events, so they need a uniform width)"
            ),
        ))
    }
}

/// Joins the `///` doc-comment lines on `attrs` into a single trimmed string, or `None` if there
/// are none. Each `///` line is a `#[doc = "..."]` attribute with a leading space.
fn doc_of(attrs: &[syn::Attribute]) -> Option<String> {
    let mut lines = Vec::new();
    for attr in attrs {
        if !attr.path().is_ident("doc") {
            continue;
        }
        if let syn::Meta::NameValue(nv) = &attr.meta {
            if let Expr::Lit(ExprLit {
                lit: Lit::Str(s), ..
            }) = &nv.value
            {
                lines.push(s.value().trim().to_string());
            }
        }
    }
    if lines.is_empty() {
        None
    } else {
        Some(lines.join("\n"))
    }
}

/// `Some("…")` → `::core::option::Option::Some("…")`, `None` → `::core::option::Option::None`.
fn opt_str(s: Option<&str>) -> TokenStream2 {
    match s {
        Some(s) => quote! { ::core::option::Option::Some(#s) },
        None => quote! { ::core::option::Option::None },
    }
}