diff --git a/Cargo.lock b/Cargo.lock index 342e0cf..b6ac70f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,14 +48,14 @@ dependencies = [ [[package]] name = "ts-bind" -version = "0.1.5" +version = "0.1.6" dependencies = [ "ts-bind-macros", ] [[package]] name = "ts-bind-macros" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "convert_case", diff --git a/Cargo.toml b/Cargo.toml index 39d38b1..45ed2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [ "crates/*" ] resolver = "2" [workspace.package] -version = "0.1.5" +version = "0.1.6" license = "MIT" edition = "2021" repository = "https://github.com/dcodesdev/ts-bind" diff --git a/README.md b/README.md index cef0abd..416f951 100644 --- a/README.md +++ b/README.md @@ -150,13 +150,24 @@ export interface User { } ``` -## Attributes +## Struct-Level Attributes -The `ts_bind` attribute supports the following optional arguments: +The `ts_bind` attribute supports the following optional arguments for the entire struct: -| Argument | Description | -| -------- | ------------------------------- | -| `rename` | Rename the generated interface. | +| Argument | Description | +| ------------ | ------------------------------- | +| `rename` | Rename the generated interface. | +| `rename_all` | Rename all fields by case. | +| `export` | Custom export path. | + +### Field-Level Attributes + +The `ts_bind` attribute supports the following optional arguments for individual fields: + +| Argument | Description | +| -------- | ----------------- | +| `rename` | Rename the field. | +| `skip` | Skip the field. | ```rust #[derive(TsBind)] @@ -178,10 +189,10 @@ export interface User { The library is far from complete. Here are some of the features that are planned: +- [x] `#[ts_bind(export = "path/to/export")]` custom export path. +- [x] `#[ts_bind(rename_all = "camelCase")]` attribute to rename all fields. +- [x] `#[ts_bind(skip)]` attribute to skip fields. - [ ] Support for enums. -- [ ] `#[ts_bind(export = "path/to/export")]` custom export path. -- [ ] `#[ts_bind(rename_all = "camelCase")]` attribute to rename all fields. -- [ ] `#[ts_bind(skip)]` attribute to skip fields. - [ ] `#[ts_bind(skip_if = "condition")]` attribute to skip fields based on a condition. ## Contributing diff --git a/crates/macros/src/files.rs b/crates/macros/src/files.rs new file mode 100644 index 0000000..4ac12e0 --- /dev/null +++ b/crates/macros/src/files.rs @@ -0,0 +1,17 @@ +use std::{ + fs::{create_dir_all, write}, + path::PathBuf, +}; + +pub fn write_to_file(path: &PathBuf, content: &str) -> anyhow::Result<()> { + let parent = path.parent().ok_or(anyhow::anyhow!( + "Failed to get parent directory of path: {}", + path.display() + ))?; + + create_dir_all(parent)?; + + write(path, content)?; + + Ok(()) +} diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 98d065a..a6ec0e0 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -1,175 +1,22 @@ -use std::{ - fs::{create_dir_all, write}, - path::PathBuf, -}; - -use convert_case::{Case, Casing}; use error::ToCompileError; -use parsers::struc::{get_nested_value, parse_struct_fields}; use proc_macro::TokenStream; -use quote::quote; use syn::{parse_macro_input, DeriveInput}; -use ts::ts_map::ts_rs_map; +use ts_bind::handle_ts_bind; mod error; +mod files; mod parsers; +mod rename_all; +mod struct_attrs; mod ts; - -#[derive(Debug)] -enum RenameAll { - CamelCase, - SnakeCase, - UpperCase, - LowerCase, - PascalCase, - // TODO: kebab - //KebabCase, -} - -impl RenameAll { - pub fn to_case(&self, s: &str) -> String { - match self { - Self::CamelCase => s.to_case(Case::Camel), - Self::SnakeCase => s.to_case(Case::Snake), - Self::UpperCase => s.to_case(Case::Upper), - Self::LowerCase => s.to_case(Case::Lower), - Self::PascalCase => s.to_case(Case::Pascal), - } - } -} - -#[derive(Default, Debug)] -struct StructAttributes { - pub rename_all: Option, - pub rename: Option, - pub export: Option, -} - -impl StructAttributes { - pub fn new() -> Self { - Self::default() - } -} +mod ts_bind; #[proc_macro_derive(TsBind, attributes(ts_bind))] pub fn ts_bind_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - let attrs = &input.attrs; - - let mut struct_attrs = StructAttributes::new(); - attrs.iter().for_each(|attr| { - if attr.path().is_ident("ts_bind") { - attr.parse_nested_meta(|meta| { - let path = &meta.path; - if path.is_ident("rename") { - let value = get_nested_value(&meta).expect("Failed to parse rename attribute"); - - struct_attrs.rename = Some(value); - } - if path.is_ident("rename_all") { - let value = - get_nested_value(&meta).expect("Failed to parse rename_all attribute"); - - match value.as_str() { - "camelCase" => { - struct_attrs.rename_all = Some(RenameAll::CamelCase); - } - "snake_case" => { - struct_attrs.rename_all = Some(RenameAll::SnakeCase); - } - "UPPERCASE" => { - struct_attrs.rename_all = Some(RenameAll::UpperCase); - } - "lowercase" => { - struct_attrs.rename_all = Some(RenameAll::LowerCase); - } - "PascalCase" => { - struct_attrs.rename_all = Some(RenameAll::PascalCase); - } - _ => { - panic!("Invalid attribute name: {}", value); - } - } - } - if path.is_ident("export") { - let value = get_nested_value(&meta).expect("Failed to parse export attribute"); - - struct_attrs.export = Some(PathBuf::from(value)); - } - - Ok(()) - }) - .expect("Failed to parse nested meta"); - } - }); - - let name = if let Some(rename) = struct_attrs.rename { - rename - } else { - input.ident.to_string() - }; - - let fields = parse_struct_fields(&input); - - if let Err(e) = fields { - return e.to_compile_error(); + match handle_ts_bind(&input) { + Ok(ts) => ts, + Err(e) => e.to_compile_error(), } - - let fields = fields.unwrap(); - - let mut ts_bind = String::from(format!("\nexport interface {} {{\n", name)); - let mut imports = Vec::new(); - for (ident, ty, attrs) in fields.iter() { - if attrs.skip { - continue; - } - - let field_name = if let Some(rename_all) = &struct_attrs.rename_all { - rename_all.to_case(&ident.to_string()) - } else { - ident.to_string() - }; - - let field_name = attrs.rename.as_ref().unwrap_or(&field_name); - - let map_result = ts_rs_map(ty, &mut imports); - - ts_bind.push_str(&format!(" {}: {};\n", field_name, map_result)); - } - - ts_bind.push_str("}"); - - sort_imports(&mut imports); - for to_import in imports { - ts_bind = format!( - "import type {{ {} }} from \"./{}\";\n{}", - to_import, to_import, ts_bind - ); - } - - ts_bind = format!( - "// This file was automatically generated by ts_bind, do not modify it manually\n{}", - ts_bind - ); - - let lib_path = if let Some(export_path) = struct_attrs.export { - export_path.join(format!("{}.ts", name)) - } else { - PathBuf::new().join("bindings").join(format!("{}.ts", name)) - }; - - write_to_file(&lib_path, &ts_bind); - - quote! {}.into() -} - -fn write_to_file(path: &PathBuf, content: &str) { - create_dir_all(path.parent().unwrap()).unwrap(); - write(path, content).unwrap(); -} - -fn sort_imports(imports: &mut Vec) { - imports.sort(); - imports.dedup(); } diff --git a/crates/macros/src/parsers/struc.rs b/crates/macros/src/parsers/struc.rs index 5bd5f6f..fc71b88 100644 --- a/crates/macros/src/parsers/struc.rs +++ b/crates/macros/src/parsers/struc.rs @@ -1,7 +1,4 @@ -use syn::{ - meta::ParseNestedMeta, Attribute, Data, DeriveInput, Expr, Fields, Ident, Lit, LitStr, Meta, - MetaNameValue, Type, -}; +use syn::{meta::ParseNestedMeta, Attribute, Data, DeriveInput, Fields, Ident, LitStr, Type}; #[derive(Default)] pub struct FieldAttributes { @@ -15,9 +12,9 @@ impl FieldAttributes { } } -pub fn parse_struct_fields( - input: &DeriveInput, -) -> anyhow::Result> { +pub type ParsedField = (Ident, Type, FieldAttributes); + +pub fn parse_struct_fields(input: &DeriveInput) -> anyhow::Result> { let mut fields_info = Vec::new(); if let Data::Struct(data_struct) = &input.data { @@ -39,38 +36,33 @@ fn parse_field_attributes(attrs: &[Attribute]) -> anyhow::Result { - let path = &meta_name_value.path; - if path.is_ident("rename") { - field_attrs.rename = get_meta_name_value(&meta_name_value)?; + attr.parse_nested_meta(|meta| { + let path = &meta.path; + + let ident = path.get_ident(); + if let Some(ident) = ident { + let ident_str = ident.to_string(); + match ident_str.as_str() { + "rename" => { + field_attrs.rename = Some( + get_nested_value(&meta).expect("Failed to parse rename attribute"), + ); } - } - Meta::Path(meta_path) => { - if meta_path.is_ident("skip") { + "skip" => { field_attrs.skip = true; } + _ => { + panic!("Invalid attribute name: {}", ident_str); + } } - Meta::List(_meta_list) => {} } - } - } - } - Ok(field_attrs) -} - -pub fn get_meta_name_value(rename_meta: &MetaNameValue) -> anyhow::Result> { - if let Expr::Lit(lit) = &rename_meta.value { - if let Lit::Str(lit_str) = &lit.lit { - return Ok(Some(lit_str.value())); + Ok(()) + })?; } - } else { - return Err(anyhow::anyhow!("rename attribute must be a string literal")); } - Ok(None) + Ok(field_attrs) } pub fn get_nested_value(meta: &ParseNestedMeta) -> anyhow::Result { diff --git a/crates/macros/src/rename_all.rs b/crates/macros/src/rename_all.rs new file mode 100644 index 0000000..2e14c61 --- /dev/null +++ b/crates/macros/src/rename_all.rs @@ -0,0 +1,24 @@ +use convert_case::{Case, Casing}; + +#[derive(Debug)] +pub enum RenameAll { + CamelCase, + SnakeCase, + UpperCase, + LowerCase, + PascalCase, + // TODO: kebab + //KebabCase, +} + +impl RenameAll { + pub fn to_case(&self, s: &str) -> String { + match self { + Self::CamelCase => s.to_case(Case::Camel), + Self::SnakeCase => s.to_case(Case::Snake), + Self::UpperCase => s.to_case(Case::Upper), + Self::LowerCase => s.to_case(Case::Lower), + Self::PascalCase => s.to_case(Case::Pascal), + } + } +} diff --git a/crates/macros/src/struct_attrs.rs b/crates/macros/src/struct_attrs.rs new file mode 100644 index 0000000..e3f2867 --- /dev/null +++ b/crates/macros/src/struct_attrs.rs @@ -0,0 +1,102 @@ +use syn::Attribute; + +use crate::{parsers::struc::get_nested_value, rename_all::RenameAll}; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct StructAttrs { + name: String, + rename_all: Option, + export: Option, +} + +impl StructAttrs { + pub fn from(struct_name: String, attrs: &Vec) -> Self { + let mut struct_attrs = Self { + name: struct_name, + rename_all: None, + export: None, + }; + + Self::parse_attrs(&mut struct_attrs, attrs); + + struct_attrs + } + + fn parse_attrs(struct_attrs: &mut Self, attrs: &Vec) { + attrs.iter().for_each(|attr| { + if attr.path().is_ident("ts_bind") { + attr.parse_nested_meta(|meta| { + let path = &meta.path; + + let ident = path.get_ident(); + + if let Some(ident) = ident { + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "rename" => { + let value = get_nested_value(&meta) + .expect("Failed to parse rename attribute"); + + struct_attrs.name = value; + } + "rename_all" => { + let value = get_nested_value(&meta) + .expect("Failed to parse rename_all attribute"); + + match value.as_str() { + "camelCase" => { + struct_attrs.rename_all = Some(RenameAll::CamelCase); + } + "snake_case" => { + struct_attrs.rename_all = Some(RenameAll::SnakeCase); + } + "UPPERCASE" => { + struct_attrs.rename_all = Some(RenameAll::UpperCase); + } + "lowercase" => { + struct_attrs.rename_all = Some(RenameAll::LowerCase); + } + "PascalCase" => { + struct_attrs.rename_all = Some(RenameAll::PascalCase); + } + _ => { + panic!("Invalid attribute name: {}", value); + } + } + } + "export" => { + let value = get_nested_value(&meta) + .expect("Failed to parse export attribute"); + + struct_attrs.export = Some(PathBuf::from(value)); + } + _ => { + panic!("Invalid attribute name: {}", ident_str); + } + } + } + + Ok(()) + }) + .expect("Failed to parse nested meta"); + } + }); + } + + pub fn get_name(&self) -> &String { + &self.name + } + + pub fn get_export_path(&self) -> PathBuf { + self.export + .clone() + .unwrap_or_else(|| PathBuf::new().join("bindings")) + .join(format!("{}.ts", self.get_name())) + } + + pub fn get_rename_all(&self) -> Option<&RenameAll> { + self.rename_all.as_ref() + } +} diff --git a/crates/macros/src/ts/gen_ts_code.rs b/crates/macros/src/ts/gen_ts_code.rs new file mode 100644 index 0000000..1fc9f18 --- /dev/null +++ b/crates/macros/src/ts/gen_ts_code.rs @@ -0,0 +1,50 @@ +use super::ts_map::ts_rs_map; +use crate::{parsers::struc::ParsedField, struct_attrs::StructAttrs}; + +pub fn gen_ts_code( + struct_name: &str, + fields: &Vec, + struct_attrs: &StructAttrs, +) -> anyhow::Result { + let mut ts_bind = String::from(format!("\nexport interface {} {{\n", struct_name)); + let mut imports = Vec::new(); + for (ident, ty, attrs) in fields.iter() { + if attrs.skip { + continue; + } + + let field_name = if let Some(rename_all) = struct_attrs.get_rename_all() { + rename_all.to_case(&ident.to_string()) + } else { + ident.to_string() + }; + + let field_name = attrs.rename.as_ref().unwrap_or(&field_name); + + let map_result = ts_rs_map(ty, &mut imports); + + ts_bind.push_str(&format!(" {}: {};\n", field_name, map_result)); + } + + ts_bind.push_str("}"); + + sorter(&mut imports); + for to_import in imports { + ts_bind = format!( + "import type {{ {} }} from \"./{}\";\n{}", + to_import, to_import, ts_bind + ); + } + + ts_bind = format!( + "// This file was automatically generated by ts_bind, do not modify it manually\n{}", + ts_bind + ); + + Ok(ts_bind) +} + +fn sorter(imports: &mut Vec) { + imports.sort(); + imports.dedup(); +} diff --git a/crates/macros/src/ts/mod.rs b/crates/macros/src/ts/mod.rs index 67f5253..b1f1224 100644 --- a/crates/macros/src/ts/mod.rs +++ b/crates/macros/src/ts/mod.rs @@ -1 +1,2 @@ +pub mod gen_ts_code; pub mod ts_map; diff --git a/crates/macros/src/ts_bind.rs b/crates/macros/src/ts_bind.rs new file mode 100644 index 0000000..585ce3f --- /dev/null +++ b/crates/macros/src/ts_bind.rs @@ -0,0 +1,20 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::DeriveInput; + +use crate::{ + files::write_to_file, parsers::struc::parse_struct_fields, struct_attrs::StructAttrs, + ts::gen_ts_code::gen_ts_code, +}; + +pub fn handle_ts_bind(input: &DeriveInput) -> anyhow::Result { + let struct_attrs = StructAttrs::from(input.ident.to_string(), &input.attrs); + + let fields = parse_struct_fields(&input)?; + + let ts_code = gen_ts_code(struct_attrs.get_name(), &fields, &struct_attrs)?; + + write_to_file(&struct_attrs.get_export_path(), &ts_code)?; + + Ok(quote! {}.into()) +}