diff --git a/Cargo.lock b/Cargo.lock index ec4d558b..3d5de458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,18 +402,18 @@ checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" [[package]] name = "proc-macro2" -version = "1.0.44" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd7356a8122b6c4a24a82b278680c73357984ca2fc79a0f9fa6dea7dced7c58" +checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.21" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" dependencies = [ "proc-macro2", ] @@ -497,7 +497,7 @@ checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.100", ] [[package]] @@ -528,6 +528,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "termcolor" version = "1.1.3" @@ -560,7 +571,7 @@ checksum = "f8b463991b4eab2d801e724172285ec4195c650e8ec79b149e6c2a8e6dd3f783" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.100", ] [[package]] @@ -596,7 +607,7 @@ name = "typeshare-annotation" version = "1.0.2" dependencies = [ "quote", - "syn", + "syn 1.0.100", ] [[package]] @@ -625,7 +636,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.18", "thiserror", ] @@ -667,7 +678,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 1.0.100", "wasm-bindgen-shared", ] @@ -689,7 +700,7 @@ checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.100", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index cfdfb8b7..f64606f6 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/1Password/typeshare" [dependencies] proc-macro2 = "1" quote = "1" -syn = { version = "1.0", features = ["full"] } +syn = { version = "2.0.18", features = ["full"] } thiserror = "1" itertools = "0.10" lazy_format = "1.8" diff --git a/core/src/helpers.rs b/core/src/helpers.rs new file mode 100644 index 00000000..4026bae6 --- /dev/null +++ b/core/src/helpers.rs @@ -0,0 +1,317 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; + +use proc_macro2::Ident; +use syn::{ + ext::IdentExt, parse::ParseBuffer, punctuated::Punctuated, Attribute, Expr, ExprLit, LitStr, + Meta, MetaList, MetaNameValue, Token, +}; + +use crate::{ + language::SupportedLanguage, + rename::RenameExt, + rust_types::{FieldDecorator, Id}, +}; + +const SERDE: &str = "serde"; +const TYPESHARE: &str = "typeshare"; + +/// Checks the given attrs for `#[typeshare]` +pub(crate) fn has_typeshare_annotation(attrs: &[syn::Attribute]) -> bool { + attrs + .iter() + .flat_map(|attr| attr.path().segments.clone()) + .any(|segment| segment.ident == TYPESHARE) +} + +pub(crate) fn serde_rename_all(attrs: &[syn::Attribute]) -> Option { + get_serde_name_value_meta_items(attrs, "rename_all").next() +} + +pub(crate) fn get_serde_name_value_meta_items<'a>( + attrs: &'a [syn::Attribute], + name: &'a str, +) -> impl Iterator + 'a { + attrs.iter().flat_map(move |attr| { + get_serde_meta_items(attr) + .iter() + .filter_map(|arg| match arg { + Meta::NameValue(name_value) if name_value.path.is_ident(name) => { + expr_to_string(&name_value.value) + } + _ => None, + }) + .collect::>() + }) +} + +// TODO: for now, this is a workaround until we can integrate serde_derive_internal +// into our parser. +/// Returns all arguments passed into `#[serde(...)]` attributes +pub(crate) fn get_serde_meta_items(attr: &syn::Attribute) -> Vec { + if attr.path().is_ident(SERDE) { + attr.parse_args_with(Punctuated::::parse_terminated) + .unwrap() + .iter() + .cloned() + .collect() + } else { + Vec::default() + } +} + +pub(crate) fn get_serialized_as_type(attrs: &[syn::Attribute]) -> Option { + get_typeshare_name_value_meta_items(attrs, "serialized_as").next() +} + +pub(crate) fn get_field_type_override(attrs: &[syn::Attribute]) -> Option { + get_typeshare_name_value_meta_items(attrs, "serialized_as").next() +} + +pub(crate) fn get_typeshare_name_value_meta_items<'a>( + attrs: &'a [syn::Attribute], + name: &'a str, +) -> impl Iterator + 'a { + attrs.iter().flat_map(move |attr| { + get_typeshare_meta_items(attr) + .iter() + .filter_map(|arg| match arg { + Meta::NameValue(name_value) if name_value.path.is_ident(name) => { + expr_to_string(&name_value.value) + } + _ => None, + }) + .collect::>() + }) +} + +/// Returns all arguments passed into `#[typeshare(...)]` attributes +pub(crate) fn get_typeshare_meta_items(attr: &syn::Attribute) -> Vec { + if attr.path().is_ident(TYPESHARE) { + attr.parse_args_with(Punctuated::::parse_terminated) + .iter() + .flat_map(|meta| meta.iter()) + .cloned() + .collect() + } else { + Vec::default() + } +} + +pub(crate) fn get_ident( + ident: Option<&proc_macro2::Ident>, + attrs: &[syn::Attribute], + rename_all: &Option, +) -> Id { + let original = ident.map_or("???".to_string(), |id| id.to_string().replace("r#", "")); + + let mut renamed = rename_all_to_case(original.clone(), rename_all); + + if let Some(s) = serde_rename(attrs) { + renamed = s; + } + + Id { original, renamed } +} + +pub(crate) fn rename_all_to_case(original: String, case: &Option) -> String { + match case { + None => original, + Some(value) => match value.as_str() { + "lowercase" => original.to_lowercase(), + "UPPERCASE" => original.to_uppercase(), + "PascalCase" => original.to_pascal_case(), + "camelCase" => original.to_camel_case(), + "snake_case" => original.to_snake_case(), + "SCREAMING_SNAKE_CASE" => original.to_screaming_snake_case(), + "kebab-case" => original.to_kebab_case(), + "SCREAMING-KEBAB-CASE" => original.to_screaming_kebab_case(), + _ => original, + }, + } +} + +pub(crate) fn serde_rename(attrs: &[syn::Attribute]) -> Option { + get_serde_name_value_meta_items(attrs, "rename").next() +} + +/// Parses any comment out of the given slice of attributes +pub(crate) fn parse_comment_attrs(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .map(|attr| attr.meta.clone()) + .filter_map(|meta| match meta { + Meta::NameValue(name_value) if name_value.path.is_ident("doc") => { + expr_to_string(&name_value.value) + } + _ => None, + }) + .collect() +} + +// `#[typeshare(skip)]` or `#[serde(skip)]` +pub(crate) fn is_skipped(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + get_serde_meta_items(attr) + .into_iter() + .chain(get_typeshare_meta_items(attr).into_iter()) + .any(|arg| matches!(arg, Meta::Path(path) if path.is_ident("skip"))) + }) +} + +fn serde_attr(attrs: &[syn::Attribute], ident: &str) -> bool { + attrs.iter().any(|attr| { + get_serde_meta_items(attr) + .iter() + .any(|arg| matches!(arg, Meta::Path(path) if path.is_ident(ident))) + }) +} + +pub(crate) fn serde_default(attrs: &[syn::Attribute]) -> bool { + serde_attr(attrs, "default") +} + +pub(crate) fn serde_flatten(attrs: &[syn::Attribute]) -> bool { + serde_attr(attrs, "flatten") +} + +/// Checks the struct or enum for decorators like `#[typeshare(typescript(readonly)]` +/// Takes a slice of `syn::Attribute`, returns a `HashMap>`, where `language` is `SupportedLanguage` +/// and `decorator` is `FieldDecorator`. Field decorators are ordered in a `BTreeSet` for consistent code generation. +pub(crate) fn get_field_decorators( + attrs: &[Attribute], +) -> HashMap> { + let languages: HashSet = SupportedLanguage::all_languages().collect(); + + attrs + .iter() + .flat_map(get_typeshare_meta_items) + .flat_map(|meta| { + if let Meta::List(list) = meta { + Some(list) + } else { + None + } + }) + .flat_map(|list: MetaList| match list.path.get_ident() { + Some(ident) if languages.contains(&ident.try_into().unwrap()) => { + Some((ident.try_into().unwrap(), list)) + } + _ => None, + }) + .map(|(language, list): (SupportedLanguage, MetaList)| { + ( + language, + list.parse_args_with(|input: &ParseBuffer| { + let mut res: Vec = vec![]; + + loop { + if input.is_empty() { + break; + } + + let ident = input.call(Ident::parse_any)?; + + // Parse `readonly` or any other single ident optionally followed by a comma + if input.peek(Token![,]) || input.is_empty() { + input.parse::().unwrap_or_default(); + res.push(Meta::Path(ident.into())); + continue; + } + + if input.is_empty() { + break; + } + + // Parse `= "any | undefined"` or any other eq sign followed by a string literal + + let eq_token = input.parse::()?; + + let value: LitStr = input.parse()?; + res.push(Meta::NameValue(MetaNameValue { + path: ident.into(), + eq_token, + value: Expr::Lit(ExprLit { + attrs: Vec::new(), + lit: value.into(), + }), + })); + + if input.is_empty() { + break; + } + + input.parse::()?; + } + Ok(res) + }) + .iter() + .flatten() + .filter_map(|nested| match nested { + Meta::Path(path) if path.segments.len() == 1 => { + Some(FieldDecorator::Word(path.get_ident()?.to_string())) + } + Meta::NameValue(name_value) => Some(FieldDecorator::NameValue( + name_value.path.get_ident()?.to_string(), + expr_to_string(&name_value.value)?, + )), + // TODO: this should throw a visible error since it suggests a malformed + // attribute. + _ => None, + }) + .collect::>(), + ) + }) + .fold(HashMap::new(), |mut acc, (language, decorators)| { + acc.entry(language).or_default().extend(decorators); + acc + }) +} + +fn expr_to_string(expr: &Expr) -> Option { + match expr { + Expr::Lit(expr_lit) => literal_to_string(&expr_lit.lit), + _ => None, + } +} + +fn literal_to_string(lit: &syn::Lit) -> Option { + match lit { + syn::Lit::Str(str) => Some(str.value().trim().to_string()), + _ => None, + } +} + +/// Checks the struct or enum for decorators like `#[typeshare(swift = "Codable, Equatable")]` +/// Takes a slice of `syn::Attribute`, returns a `HashMap>`, where `language` is `SupportedLanguage` and `decoration_words` is `String` +pub(crate) fn get_decorators(attrs: &[syn::Attribute]) -> HashMap> { + // The resulting HashMap, Key is the language, and the value is a vector of decorators words that will be put onto structures + let mut out: HashMap> = HashMap::new(); + + for value in get_typeshare_name_value_meta_items(attrs, "swift") { + let decorators: Vec = value.split(',').map(|s| s.trim().to_string()).collect(); + + // lastly, get the entry in the hashmap output and extend the value, or insert what we have already found + let decs = out.entry(SupportedLanguage::Swift).or_insert_with(Vec::new); + decs.extend(decorators); + // Sorting so all the added decorators will be after the normal ([`String`], `Codable`) in alphabetical order + decs.sort_unstable(); + decs.dedup(); //removing any duplicates just in case + } + + //return our hashmap mapping of language -> Vec + out +} + +pub(crate) fn get_tag_key(attrs: &[syn::Attribute]) -> Option { + get_serde_name_value_meta_items(attrs, "tag").next() +} + +pub(crate) fn get_content_key(attrs: &[syn::Attribute]) -> Option { + get_serde_name_value_meta_items(attrs, "content").next() +} + +/// Removes `-` characters from identifiers +pub(crate) fn remove_dash_from_identifier(name: &str) -> String { + // Dashes are not valid in identifiers, so we map them to underscores + name.replace('-', "_") +} diff --git a/core/src/language/kotlin.rs b/core/src/language/kotlin.rs index cd0e22b2..f1583439 100644 --- a/core/src/language/kotlin.rs +++ b/core/src/language/kotlin.rs @@ -2,7 +2,7 @@ use super::Language; use crate::language::SupportedLanguage; use crate::rust_types::{RustTypeFormatError, SpecialRustType}; use crate::{ - parser::remove_dash_from_identifier, + helpers::remove_dash_from_identifier, rename::RenameExt, rust_types::{RustEnum, RustEnumVariant, RustField, RustStruct, RustTypeAlias}, }; diff --git a/core/src/language/scala.rs b/core/src/language/scala.rs index c086bd4b..2fe764c0 100644 --- a/core/src/language/scala.rs +++ b/core/src/language/scala.rs @@ -3,7 +3,7 @@ use crate::language::SupportedLanguage; use crate::parser::ParsedData; use crate::rust_types::{RustType, RustTypeFormatError, SpecialRustType}; use crate::{ - parser::remove_dash_from_identifier, + helpers::remove_dash_from_identifier, rust_types::{RustEnum, RustEnumVariant, RustField, RustStruct, RustTypeAlias}, }; use itertools::Itertools; diff --git a/core/src/language/swift.rs b/core/src/language/swift.rs index 06c23c89..8e1da1e5 100644 --- a/core/src/language/swift.rs +++ b/core/src/language/swift.rs @@ -1,7 +1,7 @@ use crate::rust_types::{RustTypeFormatError, SpecialRustType}; use crate::{ + helpers::remove_dash_from_identifier, language::{Language, SupportedLanguage}, - parser::remove_dash_from_identifier, rename::RenameExt, rust_types::{RustEnum, RustEnumVariant, RustStruct, RustTypeAlias}, }; diff --git a/core/src/lib.rs b/core/src/lib.rs index 65a8c8c3..b8544afc 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,6 +7,7 @@ use thiserror::Error; mod rename; +mod helpers; /// Implementations for each language converter pub mod language; /// Parsing Rust code into a format the `language` modules can understand diff --git a/core/src/parser.rs b/core/src/parser.rs index efc7b505..b785efa0 100644 --- a/core/src/parser.rs +++ b/core/src/parser.rs @@ -1,55 +1,21 @@ -use crate::rust_types::FieldDecorator; -use crate::{ - language::SupportedLanguage, - rename::RenameExt, - rust_types::{ - Id, RustEnum, RustEnumShared, RustEnumVariant, RustEnumVariantShared, RustField, RustItem, - RustStruct, RustType, RustTypeAlias, RustTypeParseError, - }, +use crate::helpers::{ + get_content_key, get_decorators, get_field_decorators, get_field_type_override, get_ident, + get_serialized_as_type, get_tag_key, has_typeshare_annotation, is_skipped, parse_comment_attrs, + serde_default, serde_flatten, serde_rename_all, }; -use proc_macro2::{Ident, Span}; -use std::collections::BTreeSet; -use std::{ - collections::{HashMap, HashSet}, - convert::TryFrom, -}; -use syn::{Attribute, Fields, ItemEnum, ItemStruct, ItemType}; -use syn::{GenericParam, Meta, NestedMeta}; -use thiserror::Error; - -// TODO: parsing is very opinionated and makes some decisions that should be -// getting made at code generation time. Fix this. -const SERDE: &str = "serde"; -const TYPESHARE: &str = "typeshare"; +use crate::rust_types::{ + RustEnum, RustEnumShared, RustEnumVariant, RustEnumVariantShared, RustField, RustItem, + RustStruct, RustType, RustTypeAlias, RustTypeParseError, +}; -/// The results of parsing Rust source input. -#[derive(Default, Debug)] -pub struct ParsedData { - /// Structs defined in the source - pub structs: Vec, - /// Enums defined in the source - pub enums: Vec, - /// Type aliases defined in the source - pub aliases: Vec, -} +use std::convert::TryFrom; -impl ParsedData { - /// Add the parsed data from `other` to `self`. - pub fn add(&mut self, mut other: Self) { - self.structs.append(&mut other.structs); - self.enums.append(&mut other.enums); - self.aliases.append(&mut other.aliases); - } +use syn::GenericParam; +use syn::{Fields, Item, ItemEnum, ItemStruct, ItemType}; +use thiserror::Error; - fn push_rust_thing(&mut self, rust_thing: RustItem) { - match rust_thing { - RustItem::Struct(s) => self.structs.push(s), - RustItem::Enum(e) => self.enums.push(e), - RustItem::Alias(a) => self.aliases.push(a), - } - } -} +const TYPESHARE: &str = "typeshare"; /// Errors that can occur while parsing Rust source input. #[derive(Debug, Error)] @@ -79,33 +45,67 @@ pub enum ParseError { SerdeFlattenNotAllowed, } -/// Parse the given Rust source string into `ParsedData`. -pub fn parse(input: &str) -> Result { - let mut parsed_data = ParsedData::default(); +/// The results of parsing Rust source input. +#[derive(Default, Debug)] +pub struct ParsedData { + /// Structs defined in the source + pub structs: Vec, + /// Enums defined in the source + pub enums: Vec, + /// Type aliases defined in the source + pub aliases: Vec, +} - // We will only produce output for files that contain the `#[typeshare]` - // attribute, so this is a quick and easy performance win - if !input.contains("typeshare") { - return Ok(parsed_data); +impl ParsedData { + /// Add the parsed data from `other` to `self`. + pub fn add(&mut self, mut other: Self) { + self.structs.append(&mut other.structs); + self.enums.append(&mut other.enums); + self.aliases.append(&mut other.aliases); } - // Parse and process the input, ensuring we parse only items marked with - // `#[typeshare] - let source = syn::parse_file(input)?; + fn push(&mut self, rust_thing: RustItem) { + match rust_thing { + RustItem::Struct(s) => self.structs.push(s), + RustItem::Enum(e) => self.enums.push(e), + RustItem::Alias(a) => self.aliases.push(a), + } + } - for item in flatten_items(source.items.iter()) { + fn parse(&mut self, item: &Item) -> Result<(), ParseError> { match item { syn::Item::Struct(s) if has_typeshare_annotation(&s.attrs) => { - parsed_data.push_rust_thing(parse_struct(s)?); + self.push(parse_struct(s)?); } syn::Item::Enum(e) if has_typeshare_annotation(&e.attrs) => { - parsed_data.push_rust_thing(parse_enum(e)?); + self.push(parse_enum(e)?); } syn::Item::Type(t) if has_typeshare_annotation(&t.attrs) => { - parsed_data.aliases.push(parse_type_alias(t)?); + self.aliases.push(parse_type_alias(t)?); } _ => {} } + + Ok(()) + } +} + +/// Parse the given Rust source string into `ParsedData`. +pub fn parse(input: &str) -> Result { + let mut parsed_data = ParsedData::default(); + + // We will only produce output for files that contain the `#[typeshare]` + // attribute, so this is a quick and easy performance win + if !input.contains(TYPESHARE) { + return Ok(parsed_data); + } + + // Parse and process the input, ensuring we parse only items marked with + // `#[typeshare]` + let source = syn::parse_file(input)?; + + for item in flatten_items(source.items.iter()) { + parsed_data.parse(item)?; } Ok(parsed_data)