extern crate proc_macro;
use heck::{ToLowerCamelCase, ToPascalCase};
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::{Parse, ParseStream},
parse_macro_input,
punctuated::Punctuated,
Error, Fields, FieldsNamed, Ident, ItemStruct, Lit, Meta, MetaNameValue, NestedMeta, Token,
};
use ts_type::{ts_type, ToTsType, TsType};
macro_rules! abort {
($($arg:tt)*) => {{
let msg = format!($($arg)*);
return TokenStream::from(quote! {
compile_error!(#msg);
});
}};
}
struct TsArgs {
name: Option<Ident>,
extends: Option<Punctuated<Ident, Token![,]>>,
}
impl Parse for TsArgs {
fn parse(input: ParseStream) -> Result<Self, Error> {
let mut args = TsArgs {
name: None,
extends: None,
};
while !input.is_empty() {
let key = input.parse::<Ident>()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"name" => args.name = Some(input.parse()?),
"extends" => args.extends = Some(input.parse_terminated(Ident::parse)?),
_ => {
return Err(Error::new(
key.span(),
&format!("Unknown argument: `{}`", key),
))
}
}
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
Ok(args)
}
}
#[proc_macro_attribute]
pub fn ts(attr: TokenStream, input: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as TsArgs);
let item = parse_macro_input!(input as ItemStruct);
let (struct_name, fields) = match &item {
ItemStruct {
ident,
fields: Fields::Named(fields),
..
} => (ident, fields),
_ => abort!("The `ts` attribute can only be used on structs with named fields."),
};
let ts_name = match args.name {
Some(name) => format_ident!("{}", name),
None => format_ident!("I{}", struct_name),
};
let mut ts_fields = vec![];
let mut field_conversions = vec![];
let mut field_getters = vec![];
let mut processed_fields = vec![];
for field in &fields.named {
let field_type = &field.ty;
let field_name = field.ident.as_ref().unwrap();
let mut field = field.clone();
let mut doc_lines = vec![];
let mut is_optional = false;
let mut ts_field_name = format_ident!("{}", field_name.to_string().to_lower_camel_case());
let mut ts_field_type = match field_type.to_ts_type() {
Ok(ts_type) => {
let undefined = ts_type!(undefined);
if ts_type == undefined || ts_type.is_union_with(&undefined) {
is_optional = true;
}
ts_type
}
Err(err) => abort!("{}", err),
};
let mut i = 0;
while i < field.attrs.len() {
let attr = &field.attrs[i];
if attr.path.is_ident("doc") {
if let Meta::NameValue(MetaNameValue {
lit: Lit::Str(lit_str),
..
}) = attr.parse_meta().unwrap()
{
doc_lines.push(lit_str.value());
}
field.attrs.remove(i);
continue;
}
if !attr.path.is_ident("ts") {
i += 1;
continue;
}
let args_list = match attr.parse_meta() {
Ok(Meta::List(list)) => list,
_ => {
abort!(
"`ts` attribute for field `{}` must be a list, e.g. `#[ts(type = \"Js{}\")]`.",
field_name.to_string(),
field_name.to_string().to_pascal_case(),
)
}
};
for arg in args_list.nested {
match arg {
NestedMeta::Meta(Meta::NameValue(arg)) => {
let key = arg.path.get_ident().unwrap().to_string();
match key.as_str() {
"name" => {
match arg.lit {
Lit::Str(lit_str) => ts_field_name = format_ident!("{}", lit_str.value()),
_ => abort!("`name` for field `{field_name}` must be a string literal."),
};
}
"type" => {
match arg.lit {
Lit::Str(lit_str) => {
let ts_type = TsType::from_ts_str(lit_str.value().as_str());
ts_field_type = match ts_type {
Ok(ts_type) => ts_type,
Err(err) => abort!("{}", err),
}
}
_ => abort!("`type` for field `{field_name}` must be a string literal."),
};
}
"optional" => {
match arg.lit {
Lit::Bool(bool_lit) => is_optional = bool_lit.value,
_ => abort!("`optional` for field `{field_name}` must be a boolean literal."),
};
}
unknown => abort!(
r#"Unknown argument for field `{field}`: `{attr}`. Options are:
- type: The TypeScript type of the field
- name: The name of the field in the TypeScript interface
- optional: Whether the field is optional in TypeScript"#,
field = field_name.to_string(),
attr = unknown
),
}
}
_ => abort!(
"`ts` attribute for field `{}` must be a list of name-value pairs, e.g. `#[ts(type = \"{}\")]`.",
field_name.to_string(),
field_name.to_string().to_pascal_case()
)
};
}
field.attrs.remove(i);
}
let optional_char = match is_optional {
true => "?",
false => "",
};
let ts_doc_comment = match doc_lines.is_empty() {
true => "".to_string(),
false => format!("/**\n *{}\n */\n ", doc_lines.join("\n *")),
};
ts_fields.push(format!(
"{ts_doc_comment}{ts_field_name}{optional_char}: {ts_field_type};"
));
let rs_doc_comment = doc_lines.iter().map(|line| quote! { #[doc = #line] });
field_getters.push(quote! {
#(#rs_doc_comment)*
#[wasm_bindgen(method, getter = #ts_field_name)]
pub fn #field_name(this: &#ts_name) -> #field_type;
});
field_conversions.push(quote! {
#field_name: js_value.#field_name()
});
processed_fields.push(field);
}
let const_name = format_ident!("{}", &ts_name.to_string().to_uppercase());
let (extends_clause, extends) = match args.extends {
Some(extends) => (
format!(
" extends {}",
extends
.iter()
.map(|base| base.to_string())
.collect::<Vec<String>>()
.join(", ")
),
extends.into_iter().collect(),
),
None => ("".to_string(), vec![]),
};
let ts_definition = format!(
r#"interface {ts_name}{extends_clause} {{
{}
}}"#,
ts_fields.join("\n ")
);
let processed_struct = ItemStruct {
fields: Fields::Named(FieldsNamed {
named: Punctuated::from_iter(processed_fields.into_iter()),
brace_token: fields.brace_token,
}),
..item.clone()
};
let expanded = quote! {
#[wasm_bindgen(typescript_custom_section)]
const #const_name: &'static str = #ts_definition;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = #ts_name, #(extends = #extends),*)]
pub type #ts_name;
#(#field_getters)*
}
impl From<#ts_name> for #struct_name {
fn from(js_value: #ts_name) -> Self {
js_value.parse()
}
}
impl #ts_name {
pub fn parse(&self) -> #struct_name {
let js_value = self;
#struct_name {
#(#field_conversions),*
}
}
}
#[allow(unused)]
#[doc = "### Typescript Binding"]
#[doc = ""]
#[doc = "Below is the TypeScript definition for the binding generated by the `ts` attribute."]
#[doc = ""]
#[doc = "```ts"]
#[doc = #ts_definition]
#[doc = "```"]
#[doc = ""]
#processed_struct
};
TokenStream::from(expanded)
}