diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8976d2a..f8814e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ "uses": "actions-rs/cargo@v1", "with": { "command": "clippy", - "args": "--workspace --all-targets --features macro,arbitrary1,bytemuck1,num-traits02,serde1,zerocopy,std -- -Dwarnings", + "args": "--workspace --all-targets --features macro,arbitrary1,bytemuck1,num-traits02,serde1,schemars1,zerocopy,std -- -Dwarnings", }, }, ], @@ -49,7 +49,7 @@ "uses": "actions-rs/cargo@v1", "with": { "command": "test", - "args": "--workspace --features macro,arbitrary1,bytemuck1,num-traits02,serde1,zerocopy,std", + "args": "--workspace --features macro,arbitrary1,bytemuck1,num-traits02,serde1,schemars1,zerocopy,std", }, }, ], diff --git a/Cargo.lock b/Cargo.lock index 2de55b6..b3b4f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,9 @@ dependencies = [ "bounded-integer-macro", "bytemuck", "num-traits", + "schemars", "serde", + "serde_json", "trybuild", "zerocopy", ] @@ -41,6 +43,12 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "equivalent" version = "1.0.2" @@ -115,12 +123,44 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "serde" version = "1.0.217" diff --git a/Cargo.toml b/Cargo.toml index 7cc17f0..d43b5f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ bounded-integer-macro = { path = "./macro", version = "=0.6.0", optional = true arbitrary1 = { package = "arbitrary", version = "1.0.2", optional = true } bytemuck1 = { package = "bytemuck", version = "1.7.2", optional = true } num-traits02 = { package = "num-traits", version = "0.2.14", default-features = false, features = ["i128"], optional = true } +schemars1 = { package = "schemars", version = "1.0.0", default-features = false, optional = true } serde1 = { package = "serde", version = "1.0.124", default-features = false, optional = true } +serde_json1 = { package = "serde_json", version = "1.0.142", default-features = false, optional = true } zerocopy = { version = "0.8.14", features = ["derive"], optional = true } [profile.test.package.optimization-tests] @@ -28,6 +30,8 @@ macro = ["bounded-integer-macro"] step_trait = [] +schemars1 = ["alloc", "dep:schemars1", "dep:serde_json1"] + # `__doc` is set when building the crate locally, but users should not set it. # There are a few reasons we use it instead of `cfg(doc)`: # + The examples are gated based on this feature, and we want them to typecheck. diff --git a/README.md b/README.md index b25d875..ffc73f6 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ By default, no crate features are enabled. [`SaturatingSub`] for all bounded integers. - `serde1`: Implement [`Serialize`] and [`Deserialize`] for the bounded integers, making sure all values will never be out of bounds. +- `schemars1`: Implement [`JsonSchema`] for the bounded integers, describing them as their + underlying primitive constrained to the type’s range via `minimum` and `maximum`. Bounds of + `i128`/`u128`-backed integers that fall outside the `i64`/`u64` range are omitted unless + `serde_json`’s `arbitrary_precision` feature is enabled (which has a significant runtime cost). - `zerocopy`: Implement [`IntoBytes`] and [`Immutable`] for all bounded integers, [`Unaligned`] for ones backed by `u8` or `i8`, and [`FromZeros`] for suitable macro-generated ones. @@ -83,6 +87,7 @@ By default, no crate features are enabled. [`SaturatingSub`]: https://docs.rs/num-traits/0.2/num_traits/ops/saturating/trait.SaturatingSub.html [`Serialize`]: https://docs.rs/serde/1/serde/trait.Serialize.html [`Deserialize`]: https://docs.rs/serde/1/serde/trait.Deserialize.html +[`JsonSchema`]: https://docs.rs/schemars/1/schemars/trait.JsonSchema.html [`IntoBytes`]: https://docs.rs/zerocopy/0.8/zerocopy/trait.IntoBytes.html [`FromZeros`]: https://docs.rs/zerocopy/0.8/zerocopy/trait.FromZeros.html [`Immutable`]: https://docs.rs/zerocopy/0.8/zerocopy/trait.Immutable.html diff --git a/src/lib.rs b/src/lib.rs index 4a15659..d5dfb73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,10 @@ //! [`SaturatingSub`] for all bounded integers. //! - `serde1`: Implement [`Serialize`] and [`Deserialize`] for the bounded integers, making sure all //! values will never be out of bounds. +//! - `schemars1`: Implement [`JsonSchema`] for the bounded integers, describing them as their +//! underlying primitive constrained to the type’s range via `minimum` and `maximum`. Bounds of +//! `i128`/`u128`-backed integers that fall outside the `i64`/`u64` range are omitted unless +//! `serde_json`’s `arbitrary_precision` feature is enabled (which has a significant runtime cost). //! - `zerocopy`: Implement [`IntoBytes`] and [`Immutable`] for all bounded integers, //! [`Unaligned`] for ones backed by `u8` or `i8`, //! and [`FromZeros`] for suitable macro-generated ones. @@ -87,6 +91,7 @@ //! [`SaturatingSub`]: https://docs.rs/num-traits/0.2/num_traits/ops/saturating/trait.SaturatingSub.html //! [`Serialize`]: https://docs.rs/serde/1/serde/trait.Serialize.html //! [`Deserialize`]: https://docs.rs/serde/1/serde/trait.Deserialize.html +//! [`JsonSchema`]: https://docs.rs/schemars/1/schemars/trait.JsonSchema.html //! [`IntoBytes`]: https://docs.rs/zerocopy/0.8/zerocopy/trait.IntoBytes.html //! [`FromZeros`]: https://docs.rs/zerocopy/0.8/zerocopy/trait.FromZeros.html //! [`Immutable`]: https://docs.rs/zerocopy/0.8/zerocopy/trait.Immutable.html @@ -122,6 +127,9 @@ pub use prim_int::TryFromError; // Not public API. #[doc(hidden)] pub mod __private { + #[cfg(feature = "alloc")] + pub extern crate alloc; + #[cfg(feature = "arbitrary1")] pub use ::arbitrary1; @@ -131,6 +139,9 @@ pub mod __private { #[cfg(feature = "num-traits02")] pub use ::num_traits02; + #[cfg(feature = "schemars1")] + pub use ::{schemars1, serde_json1}; + #[cfg(feature = "serde1")] pub use ::serde1; @@ -155,6 +166,7 @@ pub mod __private { __cfg_arbitrary1 "arbitrary1", __cfg_bytemuck1 "bytemuck1", __cfg_num_traits02 "num-traits02", + __cfg_schemars1 "schemars1", __cfg_serde1 "serde1", __cfg_step_trait "step_trait", __cfg_zerocopy "zerocopy", diff --git a/src/macro.rs b/src/macro.rs index ff5dcbd..ec8c1d9 100644 --- a/src/macro.rs +++ b/src/macro.rs @@ -383,6 +383,106 @@ mod tests { assert_one::(); } + #[test] + #[cfg(feature = "schemars1")] + fn schemars() { + use schemars1::{generate::SchemaSettings, schema_for}; + use serde_json1::json; + + let meta = SchemaSettings::default().meta_schema.unwrap(); + + bounded_integer!(struct A(3, 12);); + assert_eq!( + schema_for!(A), + json!({ + "$schema": meta, + "type": "integer", + "title": "uint8", + "format": "uint8", + "minimum": 3, + "maximum": 12, + }) + ); + + // Signed ranges keep their negative bounds. + bounded_integer!(struct B(-10_000_000_005, -10_000_000_001);); + assert_eq!( + schema_for!(B), + json!({ + "$schema": meta, + "type": "integer", + "title": "int64", + "format": "int64", + "minimum": -10_000_000_005i64, + "maximum": -10_000_000_001i64, + }) + ); + + // Enums work just like structs. + bounded_integer!(enum C(-1, 1);); + assert_eq!( + schema_for!(C), + json!({ + "$schema": meta, + "type": "integer", + "title": "int8", + "format": "int8", + "minimum": -1, + "maximum": 1, + }) + ); + + // Including enums with named variants. + bounded_integer! { + enum Sign { + #[allow(unused)] + Negative = -1, + #[allow(unused)] + Zero, + #[allow(unused)] + Positive, + } + } + assert_eq!( + schema_for!(Sign), + json!({ + "$schema": meta, + "type": "integer", + "title": "int8", + "format": "int8", + "minimum": -1, + "maximum": 1, + }) + ); + + // A custom `repr` is reflected in the schema’s `format`. + bounded_integer!(#[repr(u16)] struct D(2, 4);); + assert_eq!( + schema_for!(D), + json!({ + "$schema": meta, + "type": "integer", + "title": "uint16", + "format": "uint16", + "minimum": 2, + "maximum": 4, + }) + ); + + // Assuming serde_json/arbitrary_precision is disabled: + // Bounds that fall outside the i64/u64 range are omitted but the type is still supported. + bounded_integer!(struct E(0x1_0000_0000_0000_0000, 0x2_0000_0000_0000_0000);); + assert_eq!( + schema_for!(E), + json!({ + "$schema": meta, + "type": "integer", + "title": "uint128", + "format": "uint128", + }) + ); + } + #[test] #[cfg(feature = "zerocopy")] fn unaligned() { diff --git a/src/types.rs b/src/types.rs index 58988b2..480d6ad 100644 --- a/src/types.rs +++ b/src/types.rs @@ -343,6 +343,49 @@ mod tests { >::try_from(8_u16).unwrap_err(); } + #[test] + #[cfg(feature = "schemars1")] + fn schemars() { + use schemars1::{generate::SchemaSettings, schema_for}; + use serde_json1::json; + + let meta = SchemaSettings::default().meta_schema.unwrap(); + + assert_eq!( + schema_for!(BoundedU16<10, 20>), + json!({ + "$schema": meta, + "type": "integer", + "title": "uint16", + "format": "uint16", + "minimum": 10, + "maximum": 20, + }) + ); + + assert_eq!( + schema_for!(BoundedI32<-8, 8>), + json!({ + "$schema": meta, + "type": "integer", + "title": "int32", + "format": "int32", + "minimum": -8, + "maximum": 8, + }) + ); + + assert_eq!( + schema_for!(BoundedI128<-0x1_0000_0000_0000_0000, 0x1_0000_0000_0000_0000>), + json!({ + "$schema": meta, + "type": "integer", + "title": "int128", + "format": "int128", + }) + ); + } + #[test] #[cfg(feature = "num-traits02")] #[expect(clippy::too_many_lines)] diff --git a/src/unsafe_api.rs b/src/unsafe_api.rs index 7930ac8..a7db63b 100644 --- a/src/unsafe_api.rs +++ b/src/unsafe_api.rs @@ -1354,6 +1354,48 @@ macro_rules! __unsafe_api_internal { } } } + + // === Schemars === + + $crate::__private::__cfg_schemars1! { + use $crate::__private::alloc::borrow::{Cow, ToOwned}; + use $crate::__private::schemars1::{JsonSchema, Schema, SchemaGenerator}; + use $crate::__private::serde_json1::to_value; + + #[automatically_derived] + impl<$($generics)*> JsonSchema for $ty where $($where)* { + // Like the primitive integers, a bounded integer is a simple constrained scalar + // that is clearer inlined than referenced through `$defs`. + fn inline_schema() -> bool { + true + } + + fn schema_name() -> Cow<'static, str> { + <$inner as JsonSchema>::schema_name() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = <$inner as JsonSchema>::json_schema(generator); + + // `to_value` only fails for `i128`/`u128` bounds outside the `i64`/`u64` + // range, which serde_json can represent solely with its `arbitrary_precision` + // feature (at significant runtime cost); when it fails we leave the bound + // unspecified. + if let Ok(minimum) = to_value(Self::MIN_VALUE) { + schema.insert("minimum".to_owned(), minimum); + } else { + schema.remove("minimum"); + } + if let Ok(maximum) = to_value(Self::MAX_VALUE) { + schema.insert("maximum".to_owned(), maximum); + } else { + schema.remove("maximum"); + } + + schema + } + } + } }; }; (@repr u8, $($rest:tt)*) => { $crate::__unsafe_api_internal! { diff --git a/ui/bad_range.stderr b/ui/bad_range.stderr index cb9adb2..b537832 100644 --- a/ui/bad_range.stderr +++ b/ui/bad_range.stderr @@ -1,5 +1,12 @@ error[E0080]: evaluation panicked: range minimum should be less than maximum - --> src/types.rs + --> src/unsafe_api.rs + | + | pub const MIN: Self = Self::new(Self::MIN_VALUE) + | ___________________________________^ + | | .expect("range minimum should be less than maximum"); + | |____________________________________________________________________^ evaluation of `bounded_integer::types::_::>::MIN` failed here + | + ::: src/types.rs | | / define_bounded_integers! { | | BoundedU8(u8, unaligned, unsigned), @@ -8,7 +15,7 @@ error[E0080]: evaluation panicked: range minimum should be less than maximum ... | | | BoundedIsize(isize), | | } - | |_^ evaluation of `bounded_integer::types::_::>::MIN` failed here + | |_- in this macro invocation | = note: this error originates in the macro `$crate::__unsafe_api_internal` which comes from the expansion of the macro `define_bounded_integers` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/ui/const_new.stderr b/ui/const_new.stderr index dcd529c..d4751c3 100644 --- a/ui/const_new.stderr +++ b/ui/const_new.stderr @@ -1,5 +1,10 @@ error[E0080]: evaluation panicked: value passed to `const_new` is out of range - --> src/types.rs + --> src/unsafe_api.rs + | + | const { Self::new(N).expect("value passed to `const_new` is out of range") } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `bounded_integer::types::_::>::const_new::<6>::{constant#0}` failed here + | + ::: src/types.rs | | / define_bounded_integers! { | | BoundedU8(u8, unaligned, unsigned), @@ -8,23 +13,28 @@ error[E0080]: evaluation panicked: value passed to `const_new` is out of range ... | | | BoundedIsize(isize), | | } - | |_^ evaluation of `bounded_integer::types::_::>::const_new::<6>::{constant#0}` failed here + | |_- in this macro invocation | = note: this error originates in the macro `$crate::__unsafe_api_internal` which comes from the expansion of the macro `define_bounded_integers` (in Nightly builds, run with -Z macro-backtrace for more info) note: erroneous constant encountered - --> src/types.rs - | - | / define_bounded_integers! { - | | BoundedU8(u8, unaligned, unsigned), - | | BoundedU16(u16, unsigned), - | | BoundedU32(u32, unsigned), -... | - | | BoundedIsize(isize), - | | } - | |_^ - | - = note: this note originates in the macro `$crate::__unsafe_api_internal` which comes from the expansion of the macro `define_bounded_integers` (in Nightly builds, run with -Z macro-backtrace for more info) + --> src/unsafe_api.rs + | + | const { Self::new(N).expect("value passed to `const_new` is out of range") } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + ::: src/types.rs + | + | / define_bounded_integers! { + | | BoundedU8(u8, unaligned, unsigned), + | | BoundedU16(u16, unsigned), + | | BoundedU32(u32, unsigned), +... | + | | BoundedIsize(isize), + | | } + | |_- in this macro invocation + | + = note: this note originates in the macro `$crate::__unsafe_api_internal` which comes from the expansion of the macro `define_bounded_integers` (in Nightly builds, run with -Z macro-backtrace for more info) note: the above error was encountered while instantiating `fn bounded_integer::types::_::>::const_new::<6>` --> ui/const_new.rs:2:13 diff --git a/ui/macro.stderr b/ui/macro.stderr index 65c1787..22899b8 100644 --- a/ui/macro.stderr +++ b/ui/macro.stderr @@ -97,8 +97,12 @@ error: cannot find attribute `another_disallowed` in this scope error[E0308]: mismatched types --> ui/macro.rs:74:29 | -74 | pub struct ReprWrong(0, 1_u16); - | ^^^^^ expected `u8`, found `u16` +72 | / bounded_integer! { +73 | | #[repr(u8)] +74 | | pub struct ReprWrong(0, 1_u16); + | | ^^^^^ expected `u8`, found `u16` +75 | | } + | |_- expected because of the type of the associated constant | help: change the type of the numeric literal from `u16` to `u8` |