Skip to main content

iota_sdk_bcs_schema/
lib.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::HashMap,
6    sync::{Mutex, OnceLock},
7};
8
9use proc_macro::TokenStream;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::quote;
12use syn::{
13    Data, DeriveInput, Expr, Fields, GenericArgument, Lit, PathArguments, Type, parse_macro_input,
14};
15
16const DEFAULT_BCS_SCHEMA_FILE: &str = "bcs-schema.abnf";
17
18#[cfg(feature = "move-shape")]
19mod move_shape;
20
21fn defined_names() -> &'static Mutex<HashMap<String, String>> {
22    static NAMES: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
23    NAMES.get_or_init(|| Mutex::new(HashMap::new()))
24}
25
26#[proc_macro_derive(BcsSchema, attributes(bcs_schema))]
27pub fn derive_bcs_schema(input: TokenStream) -> TokenStream {
28    let input = parse_macro_input!(input as DeriveInput);
29    match expand(&input) {
30        Ok(ts) => ts.into(),
31        Err(e) => e.to_compile_error().into(),
32    }
33}
34
35#[cfg(feature = "move-shape")]
36#[proc_macro_derive(MoveShape)]
37pub fn derive_move_shape(input: TokenStream) -> TokenStream {
38    let input = parse_macro_input!(input as DeriveInput);
39    match move_shape::expand(&input) {
40        Ok(ts) => ts.into(),
41        Err(e) => e.to_compile_error().into(),
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Attribute parsing
47// ---------------------------------------------------------------------------
48
49struct TypeAttrs {
50    name: Option<String>,
51    definition: Option<String>,
52}
53
54struct FieldAttrs {
55    skip: bool,
56    as_type: Option<String>,
57}
58
59struct VariantAttrs {
60    skip: bool,
61    as_type: Option<String>,
62}
63
64fn parse_type_attrs(input: &DeriveInput) -> syn::Result<TypeAttrs> {
65    let mut attrs = TypeAttrs {
66        name: None,
67        definition: None,
68    };
69    for attr in &input.attrs {
70        if !attr.path().is_ident("bcs_schema") {
71            continue;
72        }
73        attr.parse_nested_meta(|meta| {
74            if meta.path.is_ident("name") {
75                let value = meta.value()?;
76                let s: syn::LitStr = value.parse()?;
77                attrs.name = Some(s.value());
78                Ok(())
79            } else if meta.path.is_ident("definition") {
80                let value = meta.value()?;
81                let s: syn::LitStr = value.parse()?;
82                attrs.definition = Some(s.value());
83                Ok(())
84            } else {
85                Err(meta.error("expected `name` or `definition`"))
86            }
87        })?;
88    }
89    Ok(attrs)
90}
91
92fn parse_variant_attrs(variant: &syn::Variant) -> syn::Result<VariantAttrs> {
93    let mut attrs = VariantAttrs {
94        skip: false,
95        as_type: None,
96    };
97    for attr in &variant.attrs {
98        if !attr.path().is_ident("bcs_schema") {
99            continue;
100        }
101        attr.parse_nested_meta(|meta| {
102            if meta.path.is_ident("skip") {
103                attrs.skip = true;
104                Ok(())
105            } else if meta.path.is_ident("as_type") {
106                let value = meta.value()?;
107                let s: syn::LitStr = value.parse()?;
108                attrs.as_type = Some(s.value());
109                Ok(())
110            } else {
111                Err(meta.error("expected `skip` or `as_type`"))
112            }
113        })?;
114    }
115    Ok(attrs)
116}
117
118fn parse_field_attrs(field: &syn::Field) -> syn::Result<FieldAttrs> {
119    let mut attrs = FieldAttrs {
120        skip: false,
121        as_type: None,
122    };
123    for attr in &field.attrs {
124        if !attr.path().is_ident("bcs_schema") {
125            continue;
126        }
127        attr.parse_nested_meta(|meta| {
128            if meta.path.is_ident("skip") {
129                attrs.skip = true;
130                Ok(())
131            } else if meta.path.is_ident("as_type") {
132                let value = meta.value()?;
133                let s: syn::LitStr = value.parse()?;
134                attrs.as_type = Some(s.value());
135                Ok(())
136            } else {
137                Err(meta.error("expected `skip` or `as_type`"))
138            }
139        })?;
140    }
141    Ok(attrs)
142}
143
144// ---------------------------------------------------------------------------
145// Type → ABNF mapping
146// ---------------------------------------------------------------------------
147
148fn type_to_schema(ty: &Type) -> String {
149    match ty {
150        Type::Path(type_path) => {
151            let seg = match type_path.path.segments.last() {
152                Some(s) => s,
153                None => return "unknown".into(),
154            };
155            let name = seg.ident.to_string();
156            match name.as_str() {
157                "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128"
158                | "bool" => name,
159                "str" | "String" => "string".into(),
160                "Vec" => match extract_single_generic(seg) {
161                    Some(inner) if matches_type_name(&inner, "u8") => "bytes".into(),
162                    // BCS vector: `size` length prefix followed by the elements.
163                    Some(inner) => {
164                        format!("(size {})", wrap_for_repetition(&type_to_schema(&inner)))
165                    }
166                    None => "(size *unknown)".into(),
167                },
168                "Option" => match extract_single_generic(seg) {
169                    // BCS option: opt discriminant (%d00 = None, %d01 = Some) + value.
170                    Some(inner) => {
171                        let inner_str = type_to_schema(&inner);
172                        // Wrap complex inner types so the group is unambiguous.
173                        let rhs = if (inner_str.contains(' ') && !inner_str.starts_with('('))
174                            || inner_str.starts_with('*')
175                            || inner_str.starts_with('[')
176                        {
177                            format!("({inner_str})")
178                        } else {
179                            inner_str
180                        };
181                        format!("(%d00 / %d01 {rhs})")
182                    }
183                    None => "(%d00 / %d01 unknown)".into(),
184                },
185                "Box" => match extract_single_generic(seg) {
186                    Some(inner) => type_to_schema(&inner),
187                    None => "unknown".into(),
188                },
189                "BTreeMap" | "HashMap" => match extract_two_generics(seg) {
190                    // BCS map: `size` length prefix followed by key-value pairs.
191                    Some((k, v)) => {
192                        format!("(size *({} {}))", type_to_schema(&k), type_to_schema(&v))
193                    }
194                    None => "(size *(unknown unknown))".into(),
195                },
196                // A BCS set has the same wire shape as a vector; the grammar
197                // cannot express the canonical (sorted, unique) element order,
198                // which is a BCS-level semantic like map key order.
199                "BTreeSet" => match extract_single_generic(seg) {
200                    Some(inner) => {
201                        format!("(size {})", wrap_for_repetition(&type_to_schema(&inner)))
202                    }
203                    None => "(size *unknown)".into(),
204                },
205                other => to_kebab_case(other),
206            }
207        }
208        Type::Array(arr) => {
209            let elem = type_to_schema(&arr.elem);
210            if elem == "u8" {
211                if let Expr::Lit(expr_lit) = &arr.len
212                    && let Lit::Int(lit_int) = &expr_lit.lit
213                {
214                    return format!("{}OCTET", lit_int.base10_digits());
215                }
216                // Non-literal length — user should use #[bcs_schema(definition = "...")]
217                "*OCTET".into()
218            } else if let Expr::Lit(expr_lit) = &arr.len
219                && let Lit::Int(lit_int) = &expr_lit.lit
220            {
221                // NRule or N(group) — exact repetition per RFC 5234 §3.7
222                let n = lit_int.base10_digits();
223                if elem.starts_with('(') || (!elem.contains(' ') && !elem.starts_with('*')) {
224                    format!("{n}{elem}")
225                } else {
226                    format!("{n}({elem})")
227                }
228            } else {
229                wrap_for_repetition(&elem)
230            }
231        }
232        Type::Tuple(tuple) if tuple.elems.is_empty() => "unit".into(),
233        Type::Tuple(tuple) => {
234            let elems: Vec<String> = tuple.elems.iter().map(type_to_schema).collect();
235            format!("({})", elems.join(" "))
236        }
237        _ => "unknown".into(),
238    }
239}
240
241fn extract_single_generic(seg: &syn::PathSegment) -> Option<Type> {
242    if let PathArguments::AngleBracketed(args) = &seg.arguments
243        && let Some(GenericArgument::Type(ty)) = args.args.first()
244    {
245        return Some(ty.clone());
246    }
247    None
248}
249
250fn extract_two_generics(seg: &syn::PathSegment) -> Option<(Type, Type)> {
251    if let PathArguments::AngleBracketed(args) = &seg.arguments {
252        let mut iter = args.args.iter();
253        if let (Some(GenericArgument::Type(k)), Some(GenericArgument::Type(v))) =
254            (iter.next(), iter.next())
255        {
256            return Some((k.clone(), v.clone()));
257        }
258    }
259    None
260}
261
262fn matches_type_name(ty: &Type, name: &str) -> bool {
263    if let Type::Path(p) = ty
264        && let Some(seg) = p.path.segments.last()
265    {
266        return seg.ident == name;
267    }
268    false
269}
270
271// ---------------------------------------------------------------------------
272// RFC 5234 repetition helper
273// ---------------------------------------------------------------------------
274
275/// Prefix `s` with `*` to form a zero-or-more repetition per RFC 5234 §3.6.
276///
277/// If `s` is already a bracketed group (`(…)` or `[…]`) or a single bare token
278/// (no whitespace, not already a repetition), the `*` can be prepended
279/// directly. Otherwise `s` is wrapped in `(…)` first so the repetition applies
280/// to the whole expression.
281fn wrap_for_repetition(s: &str) -> String {
282    if s.starts_with('(') || s.starts_with('[') || (!s.contains(' ') && !s.starts_with('*')) {
283        format!("*{s}")
284    } else {
285        format!("*({s})")
286    }
287}
288
289// ---------------------------------------------------------------------------
290// CamelCase → kebab-case
291// ---------------------------------------------------------------------------
292
293fn to_kebab_case(s: &str) -> String {
294    let mut result = String::with_capacity(s.len() + 4);
295    let chars: Vec<char> = s.chars().collect();
296    for (i, &ch) in chars.iter().enumerate() {
297        if ch == '_' {
298            // Treat an underscore as an explicit word boundary. ABNF rule names
299            // can't contain underscores, and Move type names like
300            // `STARDUST_UPGRADE_LABEL` or `UQ32_32` reach here.
301            result.push('-');
302            continue;
303        }
304        if ch.is_uppercase() {
305            if i > 0 {
306                let prev = chars[i - 1];
307                let prev_upper = prev.is_uppercase();
308                let next_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
309                // Skip the boundary dash when the previous char was already a
310                // separator (the `_` arm above pushed one), to avoid `_-`.
311                if prev != '_' && (!prev_upper || next_lower) {
312                    result.push('-');
313                }
314            }
315            for lower in ch.to_lowercase() {
316                result.push(lower);
317            }
318        } else {
319            result.push(ch);
320        }
321    }
322    result
323}
324
325// ---------------------------------------------------------------------------
326// Schema generation for structs
327// ---------------------------------------------------------------------------
328
329fn gen_struct(schema_name: &str, data: &syn::DataStruct) -> syn::Result<String> {
330    match &data.fields {
331        Fields::Named(fields) => {
332            let mut parts: Vec<(String, String)> = Vec::new(); // (type_schema, field_name)
333            for field in &fields.named {
334                let fa = parse_field_attrs(field)?;
335                if fa.skip {
336                    continue;
337                }
338                let type_str = fa.as_type.unwrap_or_else(|| type_to_schema(&field.ty));
339                let name = field.ident.as_ref().unwrap().to_string().replace('_', "-");
340                parts.push((type_str, name));
341            }
342
343            if parts.is_empty() {
344                return Ok(format!("{schema_name} = unit"));
345            }
346            if parts.len() == 1 {
347                let (ty, nm) = &parts[0];
348                return Ok(format!("{schema_name} = {ty}   ; {nm}"));
349            }
350
351            let max_type_len = parts.iter().map(|(t, _)| t.len()).max().unwrap_or(0);
352            let indent = " ".repeat(schema_name.len() + 3); // "name = " prefix width
353            let lines: Vec<String> = parts
354                .iter()
355                .enumerate()
356                .map(|(i, (ty, name))| {
357                    let pad = " ".repeat(max_type_len - ty.len());
358                    if i == 0 {
359                        format!("{schema_name} = {ty}{pad}   ; {name}")
360                    } else {
361                        format!("{indent}{ty}{pad}   ; {name}")
362                    }
363                })
364                .collect();
365            Ok(lines.join("\n"))
366        }
367        Fields::Unnamed(fields) => {
368            if fields.unnamed.len() == 1 {
369                let field = &fields.unnamed[0];
370                let fa = parse_field_attrs(field)?;
371                let type_str = fa.as_type.unwrap_or_else(|| type_to_schema(&field.ty));
372                Ok(format!("{schema_name} = {type_str}"))
373            } else {
374                let mut types = Vec::new();
375                for field in &fields.unnamed {
376                    let fa = parse_field_attrs(field)?;
377                    types.push(fa.as_type.unwrap_or_else(|| type_to_schema(&field.ty)));
378                }
379                Ok(format!("{schema_name} = {}", types.join(" ")))
380            }
381        }
382        Fields::Unit => Ok(format!("{schema_name} = unit")),
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Schema generation for enums
388// ---------------------------------------------------------------------------
389
390fn gen_enum(schema_name: &str, data: &syn::DataEnum) -> syn::Result<String> {
391    let indent = " ".repeat(schema_name.len() + 1);
392    let mut rows: Vec<(String, String, String)> = Vec::new(); // (prefix, fields_str, variant_name)
393
394    for (idx, variant) in data.variants.iter().enumerate() {
395        let va = parse_variant_attrs(variant)?;
396        // A skipped variant is omitted from the grammar but still consumes its
397        // discriminant, so the following variants keep their `%dNN` tags. This
398        // is how reserved/deprecated slots (which deserialization rejects) are
399        // held without appearing as valid input in the schema.
400        if va.skip {
401            continue;
402        }
403        let variant_name = variant.ident.to_string();
404        let prefix = format!("%d{idx:02}");
405
406        let fields_str = match &variant.fields {
407            Fields::Unit => {
408                // A variant-level as_type allows specifying payload for unit
409                // variants that carry data only on the wire (e.g. repr-enum
410                // mirrors used for BCS schema generation).
411                match &va.as_type {
412                    Some(t) => format!(" {t}"),
413                    None => String::new(),
414                }
415            }
416            Fields::Unnamed(fields) => {
417                let mut types = Vec::new();
418                for f in &fields.unnamed {
419                    let fa = parse_field_attrs(f)?;
420                    types.push(fa.as_type.unwrap_or_else(|| type_to_schema(&f.ty)));
421                }
422                format!(" {}", types.join(" "))
423            }
424            Fields::Named(fields) => {
425                let mut types = Vec::new();
426                for f in &fields.named {
427                    let fa = parse_field_attrs(f)?;
428                    if fa.skip {
429                        continue;
430                    }
431                    types.push(fa.as_type.unwrap_or_else(|| type_to_schema(&f.ty)));
432                }
433                if types.is_empty() {
434                    String::new()
435                } else {
436                    format!(" {}", types.join(" "))
437                }
438            }
439        };
440
441        rows.push((prefix, fields_str, variant_name));
442    }
443
444    let max_body_len = rows
445        .iter()
446        .map(|(p, f, _)| p.len() + f.len())
447        .max()
448        .unwrap_or(0);
449
450    let lines: Vec<String> = rows
451        .iter()
452        .enumerate()
453        .map(|(idx, (prefix, fields_str, variant_name))| {
454            let pad = " ".repeat(max_body_len - prefix.len() - fields_str.len());
455            if idx == 0 {
456                format!("{schema_name} = {prefix}{fields_str}{pad}   ; {variant_name}")
457            } else {
458                format!("{indent}/ {prefix}{fields_str}{pad}   ; {variant_name}")
459            }
460        })
461        .collect();
462
463    Ok(lines.join("\n"))
464}
465
466// ---------------------------------------------------------------------------
467// File writing
468// ---------------------------------------------------------------------------
469
470fn schema_file_path() -> std::path::PathBuf {
471    if let Ok(p) = std::env::var("BCS_SCHEMA_FILE") {
472        return std::path::PathBuf::from(p);
473    }
474    let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
475    std::path::PathBuf::from(manifest).join(DEFAULT_BCS_SCHEMA_FILE)
476}
477
478/// Built-in BCS primitive type definitions.
479///
480/// These are seeded into every schema file so that the grammar is always
481/// self-contained.  The proc macro never derives entries for these names, so
482/// they are preserved unchanged across regeneration runs.
483fn primitive_entries() -> &'static [(&'static str, &'static str)] {
484    &[
485        ("bool", "bool    = %d00   ; false\n        / %d01   ; true"),
486        ("bytes", "bytes   = size *OCTET"),
487        ("i64", "i64     = 8OCTET"),
488        (
489            "size",
490            "size    = uleb128   ; BCS sequence/string length (ULEB128-encoded)",
491        ),
492        ("string", "string  = size *OCTET   ; UTF-8 encoded"),
493        ("u8", "u8      = 1OCTET"),
494        ("u16", "u16     = 2OCTET"),
495        ("u32", "u32     = 4OCTET"),
496        ("u64", "u64     = 8OCTET"),
497        ("u128", "u128    = 16OCTET"),
498        (
499            "uleb128",
500            "uleb128 = *(%x80-FF) %x00-7F   ; variable-length unsigned integer",
501        ),
502    ]
503}
504
505fn write_schema_entry(schema_name: &str, definition: &str) {
506    let path = schema_file_path();
507    let content = std::fs::read_to_string(&path).unwrap_or_default();
508
509    // Parse existing entries — each entry is separated by a blank line.
510    let mut entries: Vec<(String, String)> = Vec::new();
511    for block in content.split("\n\n") {
512        let trimmed = block.trim();
513        if trimmed.is_empty() {
514            continue;
515        }
516        // Skip header comments (lines that are only comments with no rule)
517        if !trimmed.contains('=') {
518            continue;
519        }
520        // Extract the rule name: text before the first " ="
521        let rule_name = if let Some(idx) = trimmed.find(" =") {
522            trimmed[..idx].trim().to_string()
523        } else if let Some(idx) = trimmed.find('=') {
524            trimmed[..idx].trim().to_string()
525        } else {
526            continue;
527        };
528        entries.push((rule_name, trimmed.to_string()));
529    }
530
531    // Replace existing entry or append
532    let mut found = false;
533    for entry in &mut entries {
534        if entry.0 == schema_name {
535            entry.1 = definition.to_string();
536            found = true;
537            break;
538        }
539    }
540    if !found {
541        entries.push((schema_name.to_string(), definition.to_string()));
542    }
543
544    // Seed primitive definitions that the proc macro never derives itself.
545    for (name, def) in primitive_entries() {
546        if !entries.iter().any(|(n, _)| n == name) {
547            entries.push((name.to_string(), def.to_string()));
548        }
549    }
550
551    // Sort by rule name for deterministic output regardless of expansion order.
552    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
553
554    // Reconstruct the file
555    let mut output =
556        String::from("; Auto-generated BCS schema definitions\n; Do not edit manually\n");
557    for (_, def) in &entries {
558        output.push('\n');
559        output.push_str(def);
560        output.push('\n');
561    }
562
563    // Best-effort write — don't break compilation if it fails
564    let _ = std::fs::write(&path, output);
565}
566
567// ---------------------------------------------------------------------------
568// Main expansion
569// ---------------------------------------------------------------------------
570
571fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
572    let type_attrs = parse_type_attrs(input)?;
573    let ident = &input.ident;
574    let schema_name = type_attrs
575        .name
576        .unwrap_or_else(|| to_kebab_case(&ident.to_string()));
577
578    {
579        let mut names = defined_names().lock().unwrap();
580        let type_name = ident.to_string();
581        if let Some(existing) = names.get(&schema_name) {
582            if existing != &type_name {
583                return Err(syn::Error::new_spanned(
584                    ident,
585                    format!(
586                        "BcsSchema: duplicate schema name `{schema_name}` (already used by `{existing}`)"
587                    ),
588                ));
589            }
590        } else {
591            names.insert(schema_name.clone(), type_name);
592        }
593    }
594
595    let definition = match type_attrs.definition {
596        Some(def) => format!("{schema_name} = {def}"),
597        None => match &input.data {
598            Data::Struct(data) => gen_struct(&schema_name, data)?,
599            Data::Enum(data) => gen_enum(&schema_name, data)?,
600            Data::Union(_) => {
601                return Err(syn::Error::new_spanned(
602                    ident,
603                    "BcsSchema cannot be derived for unions",
604                ));
605            }
606        },
607    };
608
609    // Write the definition to the schema file only when explicitly requested via
610    // the BCS_SCHEMA env var — keeps `--all-features` builds from regenerating
611    // the file during normal development.
612    let bcs_schema_enabled = std::env::var("BCS_SCHEMA").is_ok_and(|v| !v.is_empty() && v != "0");
613    if bcs_schema_enabled {
614        write_schema_entry(&schema_name, &definition);
615    }
616
617    Ok(quote! {})
618}
619
620// ---------------------------------------------------------------------------
621// Tests
622// ---------------------------------------------------------------------------
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn kebab_case() {
630        assert_eq!(to_kebab_case("Address"), "address");
631        assert_eq!(to_kebab_case("ObjectId"), "object-id");
632        assert_eq!(to_kebab_case("GasCostSummary"), "gas-cost-summary");
633        assert_eq!(to_kebab_case("TransactionV1"), "transaction-v1");
634        assert_eq!(to_kebab_case("ObjectID"), "object-id");
635        assert_eq!(to_kebab_case("BTreeMap"), "b-tree-map");
636        // Underscores are word boundaries, not literal characters (invalid in
637        // ABNF rule names). No spurious `_-` before an uppercase letter.
638        assert_eq!(
639            to_kebab_case("STARDUST_UPGRADE_LABEL"),
640            "stardust-upgrade-label"
641        );
642        assert_eq!(to_kebab_case("UQ32_32"), "uq32-32");
643        assert_eq!(to_kebab_case("UQ64_64"), "uq64-64");
644    }
645
646    fn enum_schema(source: &str) -> String {
647        let input: DeriveInput = syn::parse_str(source).unwrap();
648        let name = to_kebab_case(&input.ident.to_string());
649        let Data::Enum(data) = &input.data else {
650            panic!("expected an enum");
651        };
652        gen_enum(&name, data).unwrap()
653    }
654
655    #[test]
656    fn skip_variant_holds_its_discriminant() {
657        // The skipped variant is omitted from the grammar, but the variants
658        // after it keep the `%dNN` tag matching their discriminant.
659        let schema = enum_schema(
660            r#"
661            enum Scheme {
662                Ed25519(Ed25519Signature),
663                #[bcs_schema(skip)]
664                Bls12381Reserved,
665                Passkey(PasskeyAuthenticator),
666            }
667            "#,
668        );
669        // `Passkey` keeps tag `%d02` even though it is the second emitted
670        // alternative, and the skipped variant contributes no `%d01` line.
671        assert_eq!(
672            schema,
673            "scheme = %d00 ed25519-signature       ; Ed25519\n\
674             \x20      / %d02 passkey-authenticator   ; Passkey"
675        );
676    }
677}