From dee9c581d7e6db9f2737758362e0aa33f0c40c5d Mon Sep 17 00:00:00 2001 From: Josiah Bull Date: Tue, 3 Dec 2024 15:04:10 +1300 Subject: [PATCH] feat(indexmap): Add support for changing HashMaps to IndexMaps. --- Cargo.lock | 16 ++++++++++++---- README.md | 7 +++++++ typify-impl/Cargo.toml | 1 - typify-impl/src/convert.rs | 9 ++++++++- typify-impl/src/defaults.rs | 6 ++++-- typify-impl/src/lib.rs | 32 +++++++++++++++++++++++++++---- typify-impl/src/structs.rs | 28 ++++++++++++++++++++++----- typify-impl/src/type_entry.rs | 25 ++++++++++++++++++------ typify-impl/src/value.rs | 6 ++++-- typify-test/Cargo.toml | 3 ++- typify-test/build.rs | 36 +++++++++++++++++++++++++++++++++-- typify-test/src/main.rs | 26 +++++++++++++++++++++++++ 12 files changed, 167 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15bb7677..6f9eaacc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "heck" version = "0.5.0" @@ -506,12 +512,13 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" [[package]] name = "indexmap" -version = "2.1.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", + "serde", ] [[package]] @@ -786,7 +793,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1541daf4e4ed43a0922b7969bdc2170178bcacc5dabf7e39bc508a9fa3953a7a" dependencies = [ - "hashbrown", + "hashbrown 0.14.3", "memchr", ] @@ -1260,6 +1267,7 @@ dependencies = [ name = "typify-test" version = "0.0.0" dependencies = [ + "indexmap", "ipnetwork", "prettyplease", "regress", diff --git a/README.md b/README.md index 8b63f2bd..55e13c38 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ applied. Non-required properties with types that already have a default value (such as a `Vec`) simply get the `#[serde(default)]` attribute (so you won't see e.g. `Option>`). +### IndexMap + +By default, Typify uses `HashMap` for objects. If you prefer to use `IndexMap` +or some other object, you can specify this by calling `with_map_to_use` on the +`TypeSpaceSettings` object, and providing the full path to the type you want to +use. E.g. `::std::collections::HashMap` or `::indexmap::IndexMap`. + ### OneOf The `oneOf` construct maps to a Rust enum. Typify maps this to the various diff --git a/typify-impl/Cargo.toml b/typify-impl/Cargo.toml index dad413cf..f3971109 100644 --- a/typify-impl/Cargo.toml +++ b/typify-impl/Cargo.toml @@ -21,7 +21,6 @@ syn = { version = "2.0.90", features = ["full"] } thiserror = "2.0.3" unicode-ident = "1.0.14" - [dev-dependencies] env_logger = "0.10.2" expectorate = "1.1.0" diff --git a/typify-impl/src/convert.rs b/typify-impl/src/convert.rs index a0cfc4d9..2548fed1 100644 --- a/typify-impl/src/convert.rs +++ b/typify-impl/src/convert.rs @@ -1200,6 +1200,7 @@ impl TypeSpace { != Some(&Schema::Bool(false)) => { let type_entry = self.make_map( + self.settings.map_to_use.clone(), type_name.into_option(), property_names, additional_properties, @@ -1236,6 +1237,7 @@ impl TypeSpace { )); let type_entry = self.make_map( + self.settings.map_to_use.clone(), type_name.into_option(), &property_names, &additional_properties, @@ -1245,7 +1247,12 @@ impl TypeSpace { } None => { - let type_entry = self.make_map(type_name.into_option(), &None, &None)?; + let type_entry = self.make_map( + self.settings.map_to_use.clone(), + type_name.into_option(), + &None, + &None, + )?; Ok((type_entry, metadata)) } diff --git a/typify-impl/src/defaults.rs b/typify-impl/src/defaults.rs index 94dc1b67..9d50b0a2 100644 --- a/typify-impl/src/defaults.rs +++ b/typify-impl/src/defaults.rs @@ -199,7 +199,9 @@ impl TypeEntry { Err(Error::invalid_value()) } } - TypeEntryDetails::Map(key_id, value_id) => { + TypeEntryDetails::Map { + key_id, value_id, .. + } => { if let serde_json::Value::Object(m) = default { if m.is_empty() { Ok(DefaultKind::Intrinsic) @@ -620,7 +622,7 @@ fn all_props<'a>( // TODO Rather than an option, this should probably be something // that lets us say "explicit name" or "type to validate against" - TypeEntryDetails::Map(_, value_id) => return vec![(None, value_id, false)], + TypeEntryDetails::Map { value_id, .. } => return vec![(None, value_id, false)], _ => unreachable!(), }; diff --git a/typify-impl/src/lib.rs b/typify-impl/src/lib.rs index ff036eeb..bf8563b1 100644 --- a/typify-impl/src/lib.rs +++ b/typify-impl/src/lib.rs @@ -238,7 +238,7 @@ pub(crate) enum DefaultImpl { } /// Settings that alter type generation. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct TypeSpaceSettings { type_mod: Option, extra_derives: Vec, @@ -246,12 +246,29 @@ pub struct TypeSpaceSettings { unknown_crates: UnknownPolicy, crates: BTreeMap, + map_to_use: String, patch: BTreeMap, replace: BTreeMap, convert: Vec, } +impl Default for TypeSpaceSettings { + fn default() -> Self { + Self { + map_to_use: "::std::collections::HashMap".to_string(), + type_mod: Default::default(), + extra_derives: Default::default(), + struct_builder: Default::default(), + unknown_crates: Default::default(), + crates: Default::default(), + patch: Default::default(), + replace: Default::default(), + convert: Default::default(), + } + } +} + #[derive(Debug, Clone)] struct CrateSpec { version: CrateVers, @@ -454,6 +471,13 @@ impl TypeSpaceSettings { ); self } + + /// Specify the map implementation to use in generated types. The default is + /// `std::collections::HashMap`. + pub fn with_map_to_use(&mut self, map_to_use: String) -> &mut Self { + self.map_to_use = map_to_use; + self + } } impl TypeSpacePatch { @@ -970,9 +994,9 @@ impl<'a> Type<'a> { // Compound types TypeEntryDetails::Option(type_id) => TypeDetails::Option(type_id.clone()), TypeEntryDetails::Vec(type_id) => TypeDetails::Vec(type_id.clone()), - TypeEntryDetails::Map(key_id, value_id) => { - TypeDetails::Map(key_id.clone(), value_id.clone()) - } + TypeEntryDetails::Map { + key_id, value_id, .. + } => TypeDetails::Map(key_id.clone(), value_id.clone()), TypeEntryDetails::Set(type_id) => TypeDetails::Set(type_id.clone()), TypeEntryDetails::Box(type_id) => TypeDetails::Box(type_id.clone()), TypeEntryDetails::Tuple(types) => TypeDetails::Tuple(Box::new(types.iter().cloned())), diff --git a/typify-impl/src/structs.rs b/typify-impl/src/structs.rs index 5f8dfb47..f0bab169 100644 --- a/typify-impl/src/structs.rs +++ b/typify-impl/src/structs.rs @@ -119,6 +119,7 @@ impl TypeSpace { additional_properties @ Some(_) => { let sub_type_name = type_name.as_ref().map(|base| format!("{}_extra", base)); let map_type = self.make_map( + self.settings.map_to_use.clone(), sub_type_name, &validation.property_names, additional_properties, @@ -205,6 +206,7 @@ impl TypeSpace { pub(crate) fn make_map( &mut self, + map_to_use: String, type_name: Option, property_names: &Option>, additional_properties: &Option>, @@ -237,7 +239,12 @@ impl TypeSpace { None => self.id_for_schema(Name::Unknown, &Schema::Bool(true))?, }; - Ok(TypeEntryDetails::Map(key_id, value_id).into()) + Ok(TypeEntryDetails::Map { + map_to_use, + key_id, + value_id, + } + .into()) } /// Perform a schema conversion for a type that must be string-like. @@ -381,7 +388,14 @@ pub(crate) fn generate_serde_attr( serde_options.push(quote! { skip_serializing_if = "::std::vec::Vec::is_empty" }); DefaultFunction::Default } - (StructPropertyState::Optional, TypeEntryDetails::Map(key_id, value_id)) => { + ( + StructPropertyState::Optional, + TypeEntryDetails::Map { + map_to_use, + key_id, + value_id, + }, + ) => { serde_options.push(quote! { default }); let key_ty = type_space @@ -400,8 +414,10 @@ pub(crate) fn generate_serde_attr( skip_serializing_if = "::serde_json::Map::is_empty" }); } else { + // Append ::is_empty to the string. + let map_to_use = format!("{}::is_empty", map_to_use); serde_options.push(quote! { - skip_serializing_if = "::std::collections::HashMap::is_empty" + skip_serializing_if = #map_to_use }); } DefaultFunction::Default @@ -458,7 +474,7 @@ fn has_default( // No default specified. (Some(TypeEntryDetails::Option(_)), None) => StructPropertyState::Optional, (Some(TypeEntryDetails::Vec(_)), None) => StructPropertyState::Optional, - (Some(TypeEntryDetails::Map(..)), None) => StructPropertyState::Optional, + (Some(TypeEntryDetails::Map { .. }), None) => StructPropertyState::Optional, (Some(TypeEntryDetails::Unit), None) => StructPropertyState::Optional, (_, None) => StructPropertyState::Required, @@ -471,7 +487,9 @@ fn has_default( StructPropertyState::Optional } // Default specified is the same as the implicit default: {} - (Some(TypeEntryDetails::Map(..)), Some(serde_json::Value::Object(m))) if m.is_empty() => { + (Some(TypeEntryDetails::Map { .. }), Some(serde_json::Value::Object(m))) + if m.is_empty() => + { StructPropertyState::Optional } // Default specified is the same as the implicit default: false diff --git a/typify-impl/src/type_entry.rs b/typify-impl/src/type_entry.rs index 0d692d17..a2620562 100644 --- a/typify-impl/src/type_entry.rs +++ b/typify-impl/src/type_entry.rs @@ -146,7 +146,11 @@ pub(crate) enum TypeEntryDetails { Option(TypeId), Box(TypeId), Vec(TypeId), - Map(TypeId, TypeId), + Map { + map_to_use: String, + key_id: TypeId, + value_id: TypeId, + }, Set(TypeId), Array(TypeId, usize), Tuple(Vec), @@ -595,7 +599,7 @@ impl TypeEntry { TypeEntryDetails::Unit | TypeEntryDetails::Option(_) | TypeEntryDetails::Vec(_) - | TypeEntryDetails::Map(_, _) + | TypeEntryDetails::Map { .. } | TypeEntryDetails::Set(_) => { matches!(impl_name, TypeSpaceImpl::Default) } @@ -1618,7 +1622,11 @@ impl TypeEntry { quote! { ::std::vec::Vec<#item> } } - TypeEntryDetails::Map(key_id, value_id) => { + TypeEntryDetails::Map { + map_to_use, + key_id, + value_id, + } => { let key_ty = type_space .id_to_entry .get(key_id) @@ -1635,7 +1643,10 @@ impl TypeEntry { } else { let key_ident = key_ty.type_ident(type_space, type_mod); let value_ident = value_ty.type_ident(type_space, type_mod); - quote! { ::std::collections::HashMap<#key_ident, #value_ident> } + + let map_to_use = syn::parse_str::(map_to_use) + .expect("map type path wasn't valid"); + quote! { #map_to_use<#key_ident, #value_ident> } } } @@ -1746,7 +1757,7 @@ impl TypeEntry { | TypeEntryDetails::Struct(_) | TypeEntryDetails::Newtype(_) | TypeEntryDetails::Vec(_) - | TypeEntryDetails::Map(..) + | TypeEntryDetails::Map { .. } | TypeEntryDetails::Set(_) | TypeEntryDetails::Box(_) | TypeEntryDetails::Native(_) @@ -1815,7 +1826,9 @@ impl TypeEntry { TypeEntryDetails::Unit => "()".to_string(), TypeEntryDetails::Option(type_id) => format!("option {}", type_id.0), TypeEntryDetails::Vec(type_id) => format!("vec {}", type_id.0), - TypeEntryDetails::Map(key_id, value_id) => { + TypeEntryDetails::Map { + key_id, value_id, .. + } => { format!("map {} {}", key_id.0, value_id.0) } TypeEntryDetails::Set(type_id) => format!("set {}", type_id.0), diff --git a/typify-impl/src/value.rs b/typify-impl/src/value.rs index 58012f81..fc1d85f7 100644 --- a/typify-impl/src/value.rs +++ b/typify-impl/src/value.rs @@ -95,7 +95,9 @@ impl TypeEntry { .collect::>>()?; quote! { vec![#(#values),*] } } - TypeEntryDetails::Map(key_id, value_id) => { + TypeEntryDetails::Map { + key_id, value_id, .. + } => { let obj = value.as_object()?; let key_ty = type_space.id_to_entry.get(key_id).unwrap(); let value_ty = type_space.id_to_entry.get(value_id).unwrap(); @@ -424,7 +426,7 @@ fn value_for_struct_props( match &type_entry.details { TypeEntryDetails::Struct(_) | TypeEntryDetails::Option(_) - | TypeEntryDetails::Map(..) => (), + | TypeEntryDetails::Map { .. } => (), _ => unreachable!(), } diff --git a/typify-test/Cargo.toml b/typify-test/Cargo.toml index 2b4d7f50..6a536cc7 100644 --- a/typify-test/Cargo.toml +++ b/typify-test/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" regress = "0.10.1" serde = "1.0.215" serde_json = "1.0.133" +indexmap = { version = "2.7.0", features = ["serde"]} [build-dependencies] ipnetwork = { version = "0.20.0", features = ["schemars"] } @@ -14,4 +15,4 @@ prettyplease = "0.2.25" schemars = "0.8.21" serde = "1.0.215" syn = "2.0.90" -typify = { path = "../typify" } +typify = { path = "../typify"} diff --git a/typify-test/build.rs b/typify-test/build.rs index c8ffe9f6..83f1e36c 100644 --- a/typify-test/build.rs +++ b/typify-test/build.rs @@ -1,10 +1,10 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::{env, fs, path::Path}; use schemars::schema::Schema; use schemars::JsonSchema; use serde::Serialize; -use typify::TypeSpace; +use typify::{TypeSpace, TypeSpaceSettings}; #[allow(dead_code)] #[derive(JsonSchema)] @@ -53,6 +53,12 @@ struct WithSet { set: HashSet, } +#[allow(dead_code)] +#[derive(JsonSchema)] +struct WithMap { + map: HashMap, +} + struct LoginName; impl JsonSchema for LoginName { fn schema_name() -> String { @@ -112,6 +118,32 @@ fn main() { let mut out_file = Path::new(&env::var("OUT_DIR").unwrap()).to_path_buf(); out_file.push("codegen.rs"); fs::write(out_file, contents).unwrap(); + + // Generate with HashMap + let mut type_space = TypeSpace::new(&TypeSpaceSettings::default()); + + WithMap::add(&mut type_space); + + let contents = + prettyplease::unparse(&syn::parse2::(type_space.to_stream()).unwrap()); + + let mut out_file = Path::new(&env::var("OUT_DIR").unwrap()).to_path_buf(); + out_file.push("codegen_hashmap.rs"); + fs::write(out_file, contents).unwrap(); + + // Generate with IndexMap + let mut settings = TypeSpaceSettings::default(); + settings.with_map_to_use("::indexmap::IndexMap".to_string()); + let mut type_space = TypeSpace::new(&settings); + + WithMap::add(&mut type_space); + + let contents = + prettyplease::unparse(&syn::parse2::(type_space.to_stream()).unwrap()); + + let mut out_file = Path::new(&env::var("OUT_DIR").unwrap()).to_path_buf(); + out_file.push("codegen_indexmap.rs"); + fs::write(out_file, contents).unwrap(); } trait AddType { diff --git a/typify-test/src/main.rs b/typify-test/src/main.rs index a37401c9..7b7cdc14 100644 --- a/typify-test/src/main.rs +++ b/typify-test/src/main.rs @@ -49,3 +49,29 @@ fn test_unknown_format() { pancakes: String::new(), }; } + +mod hashmap { + include!(concat!(env!("OUT_DIR"), "/codegen_hashmap.rs")); + + #[test] + fn test_with_map() { + // Validate that a map is currently represented as a HashMap by default. + let _ = WithMap { + map: std::collections::HashMap::new(), + }; + } +} + +mod indexmap { + use indexmap::IndexMap; + + include!(concat!(env!("OUT_DIR"), "/codegen_indexmap.rs")); + + #[test] + fn test_with_map() { + // Validate that a map is represented as an IndexMap when requested. + let _ = WithMap { + map: IndexMap::new(), + }; + } +}