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}