ts_macro/
lib.rs

1extern crate proc_macro;
2
3use heck::{ToLowerCamelCase, ToPascalCase};
4use proc_macro::TokenStream;
5use quote::{format_ident, quote};
6use syn::{
7    parse::{Parse, ParseStream},
8    parse_macro_input,
9    punctuated::Punctuated,
10    Error, Fields, FieldsNamed, Ident, ItemStruct, Lit, Meta, MetaNameValue, NestedMeta, Token,
11};
12use ts_type::{ts_type, ToTsType, TsType};
13
14/// Return a [`TokenStream`] that expands into a formatted [`compile_error!`].
15///
16/// [`compile_error!`]: https://doc.rust-lang.org/std/macro.compile_error.html
17macro_rules! abort {
18    ($($arg:tt)*) => {{
19        let msg = format!($($arg)*);
20        return TokenStream::from(quote! {
21            compile_error!(#msg);
22        });
23    }};
24}
25
26struct TsArgs {
27    name: Option<Ident>,
28    extends: Option<Punctuated<Ident, Token![,]>>,
29}
30
31impl Parse for TsArgs {
32    fn parse(input: ParseStream) -> Result<Self, Error> {
33        let mut args = TsArgs {
34            name: None,
35            extends: None,
36        };
37
38        while !input.is_empty() {
39            let key = input.parse::<Ident>()?;
40            input.parse::<Token![=]>()?;
41
42            match key.to_string().as_str() {
43                "name" => args.name = Some(input.parse()?),
44                "extends" => args.extends = Some(input.parse_terminated(Ident::parse)?),
45                _ => {
46                    return Err(Error::new(
47                        key.span(),
48                        &format!("Unknown argument: `{}`", key),
49                    ))
50                }
51            }
52
53            if !input.is_empty() {
54                input.parse::<Token![,]>()?;
55            }
56        }
57
58        Ok(args)
59    }
60}
61
62/// Generate TypeScript interface bindings from a Rust struct.
63///
64/// Each field of the struct will be included in a TypeScript interface
65/// definition with camelCase field names and the corresponding TypeScript
66/// types.
67///
68/// # Example
69///
70/// ```rust
71/// #[ts]
72/// struct Token {
73///     symbol: String,
74///     /// @default 18
75///     decimals: Option<u8>,
76///     total_supply: BigInt,
77/// }
78///
79/// #[wasm_bindgen]
80/// pub fn handle_token(token: IToken) {
81///     // Access fields via JS bindings
82///     let symbol = token.symbol();
83///     let decimals = token.decimals().unwrap_or(18.into());
84///     let total_supply = token.total_supply();
85///
86///     // Convert the JS binding into the Rust struct via `parse`
87///     let token = token.parse();
88///
89///     // Convert the Rust struct into the JS binding via `Into` / `From`
90///     let token: Token = token.into();
91/// }
92/// ```
93///
94/// Under the hood, this will generate a TypeScript interface with JS bindings:
95///
96/// ```typescript
97/// interface IToken {
98///   symbol: string;
99///   /**
100///    * @default 18
101///    */
102///   decimals?: number | undefined;
103///   totalSupply: bigint;
104/// }
105/// ```
106///
107/// ## Nested Structs
108///
109/// To nest structs, the `ts` attribute must be applied to each struct
110/// individually. Then, the bindings can be used as fields in other structs.
111///
112/// ```rust
113/// #[ts]
114/// struct Order {
115///     account: String,
116///     amount: BigInt,
117///     token: IToken, // Binding to the `Token` struct
118/// }
119/// ```
120///
121/// ## Arguments
122///
123/// The `ts` attribute accepts the following arguments when applied to a struct:
124///
125/// - `name`: The name of the TypeScript interface and binding. Defaults to
126///   `I{StructName}`.
127/// - `extends`: A comma-separated list of interfaces to extend.
128///
129/// ```rust
130/// #[ts(name = JsToken)]
131/// struct Token {
132///     symbol: String,
133///     decimals: Number,
134///     total_supply: BigInt,
135/// }
136///
137/// #[ts(name = JsShareToken, extends = JsToken)]
138/// struct ShareToken {
139///     price: BigInt,
140/// }
141///
142/// #[wasm_bindgen]
143/// pub fn handle_token(token: JsShareToken) {
144///     let symbol = token.symbol(); // Access base struct fields
145/// }
146/// ```
147///
148/// This will generate the following TypeScript interfaces:
149///
150/// ```typescript
151/// interface JsToken {
152///   // ...
153/// }
154/// interface JsShareToken extends JsToken {
155///   // ...
156/// }
157/// ```
158///
159/// ## Field Arguments
160///
161/// To customize the TypeScript interface, the `ts` attribute can be applied to
162/// individual fields of the struct. The attribute accepts the following
163/// arguments when applied to a field:
164///
165/// - `name`: The  name of the field in the TypeScript interface as a string. Defaults to the
166///  camelCase version of the Rust field name.
167/// - `type`: The TypeScript type of the field as a string. Defaults to best-effort
168///   inferred.
169/// - `optional`: Whether the field is optional in TypeScript. Defaults to
170///   inferred.
171///
172/// ```rust
173/// #[ts]
174/// struct Params {
175///     #[ts(name = "specialCASING")]
176///     special_casing: String,
177///
178///    #[ts(type = "`0x${string}`")]
179///    special_format: String,
180///
181///     // The `Option` type is inferred as optional
182///     optional_field_and_value: Option<String>,
183///
184///     #[ts(optional = false)]
185///     optional_value: Option<String>,
186///
187///     // CAUTION: This will make the field optional in TypeScript, but Rust
188///     // will still expect a String, requiring manual runtime checks.
189///     #[ts(optional = true)]
190///     optional_field: String,
191/// }
192/// ```
193///
194/// This will generate the following TypeScript interface:
195///
196/// ```typescript
197/// interface IParams {
198///    specialCASING: string;
199///    specialFormat: `0x${string}`;
200///    optionalFieldAndValue?: string | undefined;
201///    optionalValue: string | undefined;
202///    optionalField?: string;
203/// }
204/// ```
205#[proc_macro_attribute]
206pub fn ts(attr: TokenStream, input: TokenStream) -> TokenStream {
207    let args = parse_macro_input!(attr as TsArgs);
208    let item = parse_macro_input!(input as ItemStruct);
209
210    // Ensure the input is a struct with named fields
211    let (struct_name, fields) = match &item {
212        ItemStruct {
213            ident,
214            fields: Fields::Named(fields),
215            ..
216        } => (ident, fields),
217        _ => abort!("The `ts` attribute can only be used on structs with named fields."),
218    };
219
220    let ts_name = match args.name {
221        Some(name) => format_ident!("{}", name),
222        None => format_ident!("I{}", struct_name),
223    };
224    let mut ts_fields = vec![];
225    let mut field_conversions = vec![];
226    let mut field_getters = vec![];
227    let mut processed_fields = vec![];
228
229    // Iterate over the fields of the struct to generate entries for the
230    // TypeScript interface and the field conversions
231    for field in &fields.named {
232        let field_type = &field.ty;
233        let field_name = field.ident.as_ref().unwrap();
234        let mut field = field.clone();
235        let mut doc_lines = vec![];
236        let mut is_optional = false;
237
238        // Convert the Rust field name to a camelCase TypeScript field name
239        let mut ts_field_name = format_ident!("{}", field_name.to_string().to_lower_camel_case());
240
241        // Convert the Rust type to a TypeScript type
242        let mut ts_field_type = match field_type.to_ts_type() {
243            Ok(ts_type) => {
244                // if the type is `undefined` or unioned with `undefined`, make
245                // it optional
246                let undefined = ts_type!(undefined);
247                if ts_type == undefined || ts_type.is_union_with(&undefined) {
248                    is_optional = true;
249                }
250
251                ts_type
252            }
253            Err(err) => abort!("{}", err),
254        };
255
256        // Iterate over the attributes of the field to extract the `ts`
257        // attribute and doc comments
258        let mut i = 0;
259        while i < field.attrs.len() {
260            let attr = &field.attrs[i];
261
262            // Collect doc comments
263            if attr.path.is_ident("doc") {
264                if let Meta::NameValue(MetaNameValue {
265                    lit: Lit::Str(lit_str),
266                    ..
267                }) = attr.parse_meta().unwrap()
268                {
269                    doc_lines.push(lit_str.value());
270                }
271                field.attrs.remove(i);
272                continue;
273            }
274
275            if !attr.path.is_ident("ts") {
276                i += 1;
277                continue;
278            }
279
280            // Ensure the attribute is a list
281            let args_list = match attr.parse_meta() {
282                Ok(Meta::List(list)) => list,
283                _ => {
284                    abort!(
285                    "`ts` attribute for field `{}` must be a list, e.g. `#[ts(type = \"Js{}\")]`.",
286                    field_name.to_string(),
287                    field_name.to_string().to_pascal_case(),
288                )
289                }
290            };
291
292            // Iterate over the items in the list and extract the values
293            for arg in args_list.nested {
294                // Ensure the items in the list are name-value pairs
295                match arg {
296                        NestedMeta::Meta(Meta::NameValue(arg)) => {
297                            let key = arg.path.get_ident().unwrap().to_string();
298
299                            // Match the key to extract the value
300                            match key.as_str() {
301                                "name" => {
302                                    match arg.lit {
303                                        Lit::Str(lit_str) => ts_field_name = format_ident!("{}", lit_str.value()),
304                                        _ => abort!("`name` for field `{field_name}` must be a string literal."),
305                                    };
306                                }
307                                "type" => {
308                                    match arg.lit {
309                                        Lit::Str(lit_str) => {
310                                            let ts_type = TsType::from_ts_str(lit_str.value().as_str());
311                                            ts_field_type = match ts_type {
312                                                Ok(ts_type) => ts_type,
313                                                Err(err) => abort!("{}", err),
314                                            }
315                                        }
316                                        _ => abort!("`type` for field `{field_name}` must be a string literal."),
317                                    };
318                                }
319                                "optional" => {
320                                    match arg.lit {
321                                        Lit::Bool(bool_lit) => is_optional = bool_lit.value,
322                                        _ => abort!("`optional` for field `{field_name}` must be a boolean literal."),
323                                    };
324                                }
325                                unknown => abort!(
326                                    r#"Unknown argument for field `{field}`: `{attr}`. Options are:
327    - type: The TypeScript type of the field
328    - name: The name of the field in the TypeScript interface
329    - optional: Whether the field is optional in TypeScript"#,
330                                    field = field_name.to_string(),
331                                    attr = unknown
332                                ),
333                            }
334                        }
335                        _ => abort!(
336                            "`ts` attribute for field `{}` must be a list of name-value pairs, e.g. `#[ts(type = \"{}\")]`.",
337                            field_name.to_string(),
338                            field_name.to_string().to_pascal_case()
339                        )
340                    };
341            }
342
343            // Remove the attribute from the field
344            field.attrs.remove(i);
345        }
346
347        // Add an entry for the TypeScript interface
348        let optional_char = match is_optional {
349            true => "?",
350            false => "",
351        };
352        let ts_doc_comment = match doc_lines.is_empty() {
353            true => "".to_string(),
354            false => format!("/**\n   *{}\n   */\n  ", doc_lines.join("\n   *")),
355        };
356        ts_fields.push(format!(
357            "{ts_doc_comment}{ts_field_name}{optional_char}: {ts_field_type};"
358        ));
359
360        // Add a getter for the field to the binding
361        let rs_doc_comment = doc_lines.iter().map(|line| quote! { #[doc = #line] });
362        field_getters.push(quote! {
363            #(#rs_doc_comment)*
364            #[wasm_bindgen(method, getter = #ts_field_name)]
365            pub fn #field_name(this: &#ts_name) -> #field_type;
366        });
367
368        // Add an entry for the `From` implementation
369        field_conversions.push(quote! {
370            #field_name: js_value.#field_name()
371        });
372
373        // Add the processed field to the struct
374        processed_fields.push(field);
375    }
376
377    // Generate the TypeScript interface definition
378    let const_name = format_ident!("{}", &ts_name.to_string().to_uppercase());
379    let (extends_clause, extends) = match args.extends {
380        Some(extends) => (
381            format!(
382                " extends {}",
383                extends
384                    .iter()
385                    .map(|base| base.to_string())
386                    .collect::<Vec<String>>()
387                    .join(", ")
388            ),
389            extends.into_iter().collect(),
390        ),
391        None => ("".to_string(), vec![]),
392    };
393    let ts_definition = format!(
394        r#"interface {ts_name}{extends_clause} {{
395  {}
396}}"#,
397        ts_fields.join("\n  ")
398    );
399
400    // Prep the expanded struct with the processed attributes removed
401    let processed_struct = ItemStruct {
402        fields: Fields::Named(FieldsNamed {
403            named: Punctuated::from_iter(processed_fields.into_iter()),
404            brace_token: fields.brace_token,
405        }),
406        ..item.clone()
407    };
408
409    let expanded = quote! {
410        #[wasm_bindgen(typescript_custom_section)]
411        const #const_name: &'static str = #ts_definition;
412
413        #[wasm_bindgen]
414        extern "C" {
415            #[wasm_bindgen(typescript_type = #ts_name, #(extends = #extends),*)]
416            pub type #ts_name;
417
418            #(#field_getters)*
419        }
420
421        impl From<#ts_name> for #struct_name {
422            /// Convert the JS binding into the Rust struct
423            fn from(js_value: #ts_name) -> Self {
424                js_value.parse()
425            }
426        }
427
428        impl #ts_name {
429            /// Parse the JS binding into its Rust struct
430            pub fn parse(&self) -> #struct_name {
431                let js_value = self;
432                #struct_name {
433                    #(#field_conversions),*
434                }
435            }
436        }
437
438        #[allow(unused)]
439        #[doc = "### Typescript Binding"]
440        #[doc = ""]
441        #[doc = "Below is the TypeScript definition for the binding generated by the `ts` attribute."]
442        #[doc = ""]
443        #[doc = "```ts"]
444        #[doc = #ts_definition]
445        #[doc = "```"]
446        #[doc = ""]
447        #processed_struct
448    };
449
450    TokenStream::from(expanded)
451}