jsonschema_rs 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 511735bb41a48b95a424e5b92436aafe412822923eb943c0afd831ccbbcc94f8
4
- data.tar.gz: 15ee16fe53ab1c97071e21cce8cf2f3ff5b74d758d63319f25133f5165a8c74c
3
+ metadata.gz: 51236b02371703841ea00783cfc0a9d5b97281a27a527c390795ab02f5e88aac
4
+ data.tar.gz: 87fb69c295b4a82d16d20fab2368aa9eb7b90167f2135b411ee8b0279db0399d
5
5
  SHA512:
6
- metadata.gz: ead73ff80442e834f88215faa2c7d56f90bfdc4d8d3340211a313f74ba0ded0cb0da35e4400fb6554aafd3e0295b6c2ba18f94f26768cda847192941549f0c58
7
- data.tar.gz: 5af852de21e182955493ab5dc5ffc0b7e71735128d98247508d0ff153432014387590398ca38c138ff72d2cd55bbf2ab4db0a32e83f7da810769c6ad5eeb239c
6
+ metadata.gz: abc58a0cf0669380c5c3035a76586e45d952584cf5742878a451a10dab18ebfb5a7210bf80f022da8a3b302ead4364337554544537f7432e46161343b8cf7888
7
+ data.tar.gz: 991fdcf4e5661180a09918c7bce74c3afd9adf73543b8f1ba4d0f2c046e2feccb4cb9bb757e3f315c126665d67455eaecd938d24338b8dd18c0f30b4b897ca59
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.44.0] - 2026-03-02
6
+
7
+ ### Added
8
+
9
+ - `Canonical::JSON.to_string(object)` for canonical JSON serialization (stable key ordering and numeric normalization), useful for deduplicating equivalent JSON Schemas.
10
+
11
+ ### Fixed
12
+
13
+ - Do not produce annotations for non-string instances from `contentMediaType`, `contentEncoding`, and `contentSchema` keywords.
14
+
5
15
  ## [0.43.0] - 2026-02-28
6
16
 
7
17
  ### Added
@@ -43,7 +53,8 @@
43
53
 
44
54
  - Initial public release
45
55
 
46
- [Unreleased]: https://github.com/Stranger6667/jsonschema/compare/ruby-v0.43.0...HEAD
56
+ [Unreleased]: https://github.com/Stranger6667/jsonschema/compare/ruby-v0.44.0...HEAD
57
+ [0.44.0]: https://github.com/Stranger6667/jsonschema/compare/ruby-v0.43.0...ruby-v0.44.0
47
58
  [0.43.0]: https://github.com/Stranger6667/jsonschema/compare/ruby-v0.42.2...ruby-v0.43.0
48
59
  [0.42.2]: https://github.com/Stranger6667/jsonschema/compare/ruby-v0.42.1...ruby-v0.42.2
49
60
  [0.42.1]: https://github.com/Stranger6667/jsonschema/compare/ruby-v0.42.0...ruby-v0.42.1
data/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsonschema-rb"
3
- version = "0.43.0"
3
+ version = "0.44.0"
4
4
  edition = "2021"
5
5
  authors = ["Dmitry Dygalo <dmitry@dygalo.dev>"]
6
6
  license = "MIT"
@@ -13,7 +13,7 @@ publish = false
13
13
  crate-type = ["cdylib"]
14
14
 
15
15
  [dependencies]
16
- jsonschema = { version = "0.43.0", default-features = false, features = ["arbitrary-precision", "resolve-http", "resolve-file", "tls-ring"] }
16
+ jsonschema = { version = "0.44.0", default-features = false, features = ["arbitrary-precision", "resolve-http", "resolve-file", "tls-ring"] }
17
17
  magnus = { version = "0.8", features = ["rb-sys"] }
18
18
  rb-sys = "0.9"
19
19
  serde = { workspace = true }
data/README.md CHANGED
@@ -251,6 +251,22 @@ valid_eval.annotations
251
251
  # instanceLocation: "", annotations: ["age", "name"]}]
252
252
  ```
253
253
 
254
+ ### Canonical JSON serialization
255
+
256
+ Use `Canonical::JSON.to_string` when you need a stable JSON representation:
257
+
258
+ ```ruby
259
+ schema_a = { "type" => "object", "properties" => { "b" => { "type" => "integer" }, "a" => { "type" => "string" } } }
260
+ schema_b = { "properties" => { "a" => { "type" => "string" }, "b" => { "type" => "integer" } }, "type" => "object" }
261
+
262
+ dump_a = JSONSchema::Canonical::JSON.to_string(schema_a)
263
+ dump_b = JSONSchema::Canonical::JSON.to_string(schema_b)
264
+
265
+ dump_a == dump_b # => true
266
+ ```
267
+
268
+ Main use case: deduplicating equivalent JSON Schemas.
269
+
254
270
  ## Meta-Schema Validation
255
271
 
256
272
  Validate that a JSON Schema document is itself valid:
@@ -98,9 +98,9 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
98
98
 
99
99
  [[package]]
100
100
  name = "bumpalo"
101
- version = "3.19.1"
101
+ version = "3.20.2"
102
102
  source = "registry+https://github.com/rust-lang/crates.io-index"
103
- checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
103
+ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
104
104
 
105
105
  [[package]]
106
106
  name = "bytecount"
@@ -281,9 +281,9 @@ dependencies = [
281
281
 
282
282
  [[package]]
283
283
  name = "futures-channel"
284
- version = "0.3.31"
284
+ version = "0.3.32"
285
285
  source = "registry+https://github.com/rust-lang/crates.io-index"
286
- checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
286
+ checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
287
287
  dependencies = [
288
288
  "futures-core",
289
289
  "futures-sink",
@@ -291,33 +291,33 @@ dependencies = [
291
291
 
292
292
  [[package]]
293
293
  name = "futures-core"
294
- version = "0.3.31"
294
+ version = "0.3.32"
295
295
  source = "registry+https://github.com/rust-lang/crates.io-index"
296
- checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
296
+ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
297
297
 
298
298
  [[package]]
299
299
  name = "futures-io"
300
- version = "0.3.31"
300
+ version = "0.3.32"
301
301
  source = "registry+https://github.com/rust-lang/crates.io-index"
302
- checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
302
+ checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
303
303
 
304
304
  [[package]]
305
305
  name = "futures-sink"
306
- version = "0.3.31"
306
+ version = "0.3.32"
307
307
  source = "registry+https://github.com/rust-lang/crates.io-index"
308
- checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
308
+ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
309
309
 
310
310
  [[package]]
311
311
  name = "futures-task"
312
- version = "0.3.31"
312
+ version = "0.3.32"
313
313
  source = "registry+https://github.com/rust-lang/crates.io-index"
314
- checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
314
+ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
315
315
 
316
316
  [[package]]
317
317
  name = "futures-util"
318
- version = "0.3.31"
318
+ version = "0.3.32"
319
319
  source = "registry+https://github.com/rust-lang/crates.io-index"
320
- checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
320
+ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
321
321
  dependencies = [
322
322
  "futures-core",
323
323
  "futures-io",
@@ -325,7 +325,6 @@ dependencies = [
325
325
  "futures-task",
326
326
  "memchr",
327
327
  "pin-project-lite",
328
- "pin-utils",
329
328
  "slab",
330
329
  ]
331
330
 
@@ -657,9 +656,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
657
656
 
658
657
  [[package]]
659
658
  name = "js-sys"
660
- version = "0.3.85"
659
+ version = "0.3.91"
661
660
  source = "registry+https://github.com/rust-lang/crates.io-index"
662
- checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
661
+ checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
663
662
  dependencies = [
664
663
  "once_cell",
665
664
  "wasm-bindgen",
@@ -667,9 +666,9 @@ dependencies = [
667
666
 
668
667
  [[package]]
669
668
  name = "jsonschema"
670
- version = "0.43.0"
669
+ version = "0.44.0"
671
670
  source = "registry+https://github.com/rust-lang/crates.io-index"
672
- checksum = "2dcfbe6df48e0121219eefc8d6a30b872ac2769c7896454bda06f7b64129fa22"
671
+ checksum = "267fb27be492e66ab147d2ce0233d88ae465b93c3565016e73998729bf3fe60f"
673
672
  dependencies = [
674
673
  "ahash",
675
674
  "bytecount",
@@ -697,7 +696,7 @@ dependencies = [
697
696
 
698
697
  [[package]]
699
698
  name = "jsonschema-rb-ext"
700
- version = "0.43.0"
699
+ version = "0.44.0"
701
700
  dependencies = [
702
701
  "jsonschema",
703
702
  "magnus",
@@ -940,9 +939,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
940
939
 
941
940
  [[package]]
942
941
  name = "pin-project-lite"
943
- version = "0.2.16"
942
+ version = "0.2.17"
944
943
  source = "registry+https://github.com/rust-lang/crates.io-index"
945
- checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
944
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
946
945
 
947
946
  [[package]]
948
947
  name = "pin-utils"
@@ -1044,9 +1043,9 @@ dependencies = [
1044
1043
 
1045
1044
  [[package]]
1046
1045
  name = "referencing"
1047
- version = "0.43.0"
1046
+ version = "0.44.0"
1048
1047
  source = "registry+https://github.com/rust-lang/crates.io-index"
1049
- checksum = "37add1aa1d619a975521d262d09f100f1f767791c9386c03679450f30acd78c1"
1048
+ checksum = "12ecd0f3daefd4faff2e0821310c18e6e9d1fd00550bbd7e5a59d78184a071bc"
1050
1049
  dependencies = [
1051
1050
  "ahash",
1052
1051
  "fluent-uri",
@@ -1082,9 +1081,9 @@ dependencies = [
1082
1081
 
1083
1082
  [[package]]
1084
1083
  name = "regex-syntax"
1085
- version = "0.8.9"
1084
+ version = "0.8.10"
1086
1085
  source = "registry+https://github.com/rust-lang/crates.io-index"
1087
- checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
1086
+ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
1088
1087
 
1089
1088
  [[package]]
1090
1089
  name = "reqwest"
@@ -1147,9 +1146,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
1147
1146
 
1148
1147
  [[package]]
1149
1148
  name = "rustls"
1150
- version = "0.23.36"
1149
+ version = "0.23.37"
1151
1150
  source = "registry+https://github.com/rust-lang/crates.io-index"
1152
- checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
1151
+ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
1153
1152
  dependencies = [
1154
1153
  "once_cell",
1155
1154
  "ring",
@@ -1250,9 +1249,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1250
1249
 
1251
1250
  [[package]]
1252
1251
  name = "security-framework"
1253
- version = "3.6.0"
1252
+ version = "3.7.0"
1254
1253
  source = "registry+https://github.com/rust-lang/crates.io-index"
1255
- checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
1254
+ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1256
1255
  dependencies = [
1257
1256
  "bitflags",
1258
1257
  "core-foundation",
@@ -1263,9 +1262,9 @@ dependencies = [
1263
1262
 
1264
1263
  [[package]]
1265
1264
  name = "security-framework-sys"
1266
- version = "2.16.0"
1265
+ version = "2.17.0"
1267
1266
  source = "registry+https://github.com/rust-lang/crates.io-index"
1268
- checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
1267
+ checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1269
1268
  dependencies = [
1270
1269
  "core-foundation-sys",
1271
1270
  "libc",
@@ -1368,9 +1367,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1368
1367
 
1369
1368
  [[package]]
1370
1369
  name = "syn"
1371
- version = "2.0.115"
1370
+ version = "2.0.117"
1372
1371
  source = "registry+https://github.com/rust-lang/crates.io-index"
1373
- checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
1372
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1374
1373
  dependencies = [
1375
1374
  "proc-macro2",
1376
1375
  "quote",
@@ -1542,9 +1541,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
1542
1541
 
1543
1542
  [[package]]
1544
1543
  name = "unicode-ident"
1545
- version = "1.0.23"
1544
+ version = "1.0.24"
1546
1545
  source = "registry+https://github.com/rust-lang/crates.io-index"
1547
- checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
1546
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1548
1547
 
1549
1548
  [[package]]
1550
1549
  name = "untrusted"
@@ -1628,9 +1627,9 @@ dependencies = [
1628
1627
 
1629
1628
  [[package]]
1630
1629
  name = "wasm-bindgen"
1631
- version = "0.2.108"
1630
+ version = "0.2.114"
1632
1631
  source = "registry+https://github.com/rust-lang/crates.io-index"
1633
- checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
1632
+ checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
1634
1633
  dependencies = [
1635
1634
  "cfg-if",
1636
1635
  "once_cell",
@@ -1641,9 +1640,9 @@ dependencies = [
1641
1640
 
1642
1641
  [[package]]
1643
1642
  name = "wasm-bindgen-futures"
1644
- version = "0.4.58"
1643
+ version = "0.4.64"
1645
1644
  source = "registry+https://github.com/rust-lang/crates.io-index"
1646
- checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
1645
+ checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
1647
1646
  dependencies = [
1648
1647
  "cfg-if",
1649
1648
  "futures-util",
@@ -1655,9 +1654,9 @@ dependencies = [
1655
1654
 
1656
1655
  [[package]]
1657
1656
  name = "wasm-bindgen-macro"
1658
- version = "0.2.108"
1657
+ version = "0.2.114"
1659
1658
  source = "registry+https://github.com/rust-lang/crates.io-index"
1660
- checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
1659
+ checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
1661
1660
  dependencies = [
1662
1661
  "quote",
1663
1662
  "wasm-bindgen-macro-support",
@@ -1665,9 +1664,9 @@ dependencies = [
1665
1664
 
1666
1665
  [[package]]
1667
1666
  name = "wasm-bindgen-macro-support"
1668
- version = "0.2.108"
1667
+ version = "0.2.114"
1669
1668
  source = "registry+https://github.com/rust-lang/crates.io-index"
1670
- checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
1669
+ checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
1671
1670
  dependencies = [
1672
1671
  "bumpalo",
1673
1672
  "proc-macro2",
@@ -1678,18 +1677,18 @@ dependencies = [
1678
1677
 
1679
1678
  [[package]]
1680
1679
  name = "wasm-bindgen-shared"
1681
- version = "0.2.108"
1680
+ version = "0.2.114"
1682
1681
  source = "registry+https://github.com/rust-lang/crates.io-index"
1683
- checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
1682
+ checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
1684
1683
  dependencies = [
1685
1684
  "unicode-ident",
1686
1685
  ]
1687
1686
 
1688
1687
  [[package]]
1689
1688
  name = "web-sys"
1690
- version = "0.3.85"
1689
+ version = "0.3.91"
1691
1690
  source = "registry+https://github.com/rust-lang/crates.io-index"
1692
- checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
1691
+ checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
1693
1692
  dependencies = [
1694
1693
  "js-sys",
1695
1694
  "wasm-bindgen",
@@ -1978,18 +1977,18 @@ dependencies = [
1978
1977
 
1979
1978
  [[package]]
1980
1979
  name = "zerocopy"
1981
- version = "0.8.39"
1980
+ version = "0.8.40"
1982
1981
  source = "registry+https://github.com/rust-lang/crates.io-index"
1983
- checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
1982
+ checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
1984
1983
  dependencies = [
1985
1984
  "zerocopy-derive",
1986
1985
  ]
1987
1986
 
1988
1987
  [[package]]
1989
1988
  name = "zerocopy-derive"
1990
- version = "0.8.39"
1989
+ version = "0.8.40"
1991
1990
  source = "registry+https://github.com/rust-lang/crates.io-index"
1992
- checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
1991
+ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
1993
1992
  dependencies = [
1994
1993
  "proc-macro2",
1995
1994
  "quote",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsonschema-rb-ext"
3
- version = "0.43.0"
3
+ version = "0.44.0"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
@@ -10,10 +10,10 @@ name = "jsonschema_rb"
10
10
  path = "../../src/lib.rs"
11
11
 
12
12
  [dependencies]
13
- jsonschema = { version = "0.43.0", default-features = false, features = ["arbitrary-precision", "resolve-http", "resolve-file", "tls-ring"] }
13
+ jsonschema = { version = "0.44.0", default-features = false, features = ["arbitrary-precision", "resolve-http", "resolve-file", "tls-ring"] }
14
14
  magnus = { version = "0.8", features = ["rb-sys"] }
15
15
  rb-sys = "0.9"
16
- referencing = "0.43.0"
16
+ referencing = "0.44.0"
17
17
  serde = { version = "1", features = ["derive"] }
18
18
  serde_json = { version = "1", features = ["arbitrary_precision"] }
19
19
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONSchema
4
- VERSION = "0.43.0"
4
+ VERSION = "0.44.0"
5
5
  end
data/sig/jsonschema.rbs CHANGED
@@ -7,6 +7,15 @@ module JSONSchema
7
7
  # Valid draft version symbols
8
8
  type draft = :draft4 | :draft6 | :draft7 | :draft201909 | :draft202012
9
9
 
10
+ module Canonical
11
+ module JSON
12
+ # Serialize a Ruby value to canonical JSON.
13
+ #
14
+ # Main use case: deduplicate equivalent JSON Schemas by using a stable string form.
15
+ def self.to_string: (untyped object) -> String
16
+ end
17
+ end
18
+
10
19
  # Create a validator with auto-detected draft version.
11
20
  #
12
21
  # @param schema The JSON Schema (Hash or JSON string)
data/src/lib.rs CHANGED
@@ -42,7 +42,7 @@ use crate::{
42
42
  },
43
43
  registry::Registry,
44
44
  retriever::{retriever_error_message, RubyRetriever},
45
- ser::{to_schema_value, to_value},
45
+ ser::{to_canonical_string, to_schema_value, to_value},
46
46
  static_id::define_rb_intern,
47
47
  };
48
48
 
@@ -838,6 +838,15 @@ fn validator_for(ruby: &Ruby, args: &[Value]) -> Result<Validator, Error> {
838
838
  })
839
839
  }
840
840
 
841
+ /// to_string(object) -> String
842
+ ///
843
+ /// Serialize a Ruby value to canonical JSON.
844
+ ///
845
+ /// Main use case: deduplicating equivalent JSON Schemas using a stable string form.
846
+ fn canonical_json_to_string(ruby: &Ruby, object: Value) -> Result<String, Error> {
847
+ to_canonical_string(ruby, object)
848
+ }
849
+
841
850
  #[allow(unsafe_code)]
842
851
  fn is_valid(ruby: &Ruby, args: &[Value]) -> Result<bool, Error> {
843
852
  let parsed_args = scan_args::<(Value, Value), (), (), (), _, ()>(args)?;
@@ -1327,6 +1336,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1327
1336
  module.define_singleton_method("each_error", function!(each_error, -1))?;
1328
1337
  module.define_singleton_method("evaluate", function!(evaluate, -1))?;
1329
1338
 
1339
+ let canonical_module = module.define_module("Canonical")?;
1340
+ let canonical_json_module = canonical_module.define_module("JSON")?;
1341
+ canonical_json_module
1342
+ .define_singleton_method("to_string", function!(canonical_json_to_string, 1))?;
1343
+
1330
1344
  // Validator class
1331
1345
  let validator_class = module.define_class("Validator", ruby.class_object())?;
1332
1346
  validator_class.define_method("valid?", method!(Validator::is_valid, 1))?;
data/src/ser.rs CHANGED
@@ -1,5 +1,7 @@
1
1
  //! Serialization between Ruby values and `serde_json::Value`.
2
2
  use magnus::{
3
+ error::ErrorType,
4
+ exception::ExceptionClass,
3
5
  gc::register_mark_object,
4
6
  prelude::*,
5
7
  rb_sys::AsRawValue,
@@ -7,8 +9,15 @@ use magnus::{
7
9
  Error, Integer, RArray, RClass, RHash, RString, Ruby, Symbol, TryConvert, Value,
8
10
  };
9
11
  use rb_sys::{ruby_value_type, RB_TYPE};
12
+ use serde::{
13
+ ser::{SerializeMap, SerializeSeq},
14
+ Serialize, Serializer,
15
+ };
10
16
  use serde_json::{Map, Number, Value as JsonValue};
11
- use std::fmt;
17
+ use std::{
18
+ cell::RefCell,
19
+ fmt::{self, Write},
20
+ };
12
21
 
13
22
  static BIG_DECIMAL_CLASS: Lazy<RClass> = Lazy::new(|ruby| {
14
23
  // Ensure bigdecimal is loaded
@@ -23,6 +32,15 @@ static BIG_DECIMAL_CLASS: Lazy<RClass> = Lazy::new(|ruby| {
23
32
  });
24
33
 
25
34
  const RECURSION_LIMIT: u16 = 255;
35
+ const I64_LOWER_INCLUSIVE_F64: f64 = -9_223_372_036_854_775_808.0;
36
+ const I64_UPPER_EXCLUSIVE_F64: f64 = 9_223_372_036_854_775_808.0;
37
+ const U64_UPPER_EXCLUSIVE_F64: f64 = 18_446_744_073_709_551_616.0;
38
+ const IEEE754_F64_FRAC_BITS: u32 = 52;
39
+ const IEEE754_F64_EXP_BIAS: i32 = 1023;
40
+ const DECIMAL_BASE_U64: u64 = 1_000_000_000;
41
+ const DECIMAL_CHUNK_WIDTH: usize = 9;
42
+ const CANONICAL_ERROR_PREFIX: &str = "__jsonschema_rb_canonical_error__";
43
+ const DUPLICATE_CANONICAL_KEY_MESSAGE: &str = "Hash contains duplicate keys after normalization";
26
44
 
27
45
  #[inline]
28
46
  pub fn to_value(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
@@ -52,6 +70,25 @@ pub fn to_schema_value(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
52
70
  to_value_typed(ruby, value, value_type, 0)
53
71
  }
54
72
 
73
+ /// Serialize a Ruby value to canonical JSON.
74
+ ///
75
+ /// Used to generate a stable string form for deduplicating equivalent schemas.
76
+ #[inline]
77
+ pub fn to_canonical_string(ruby: &Ruby, value: Value) -> Result<String, Error> {
78
+ let mut output = Vec::new();
79
+ let mut serializer = serde_json::Serializer::new(&mut output);
80
+ let scratch_pool = RefCell::new(Vec::new());
81
+ CanonicalRubyValue::new(ruby, value, 0, &scratch_pool)
82
+ .serialize(&mut serializer)
83
+ .map_err(|error| canonical_serde_error_to_ruby(ruby, &error))?;
84
+ String::from_utf8(output).map_err(|_| {
85
+ Error::new(
86
+ ruby.exception_runtime_error(),
87
+ "Internal UTF-8 serialization error",
88
+ )
89
+ })
90
+ }
91
+
55
92
  fn to_value_recursive(ruby: &Ruby, value: Value, depth: u16) -> Result<JsonValue, Error> {
56
93
  if value.is_nil() {
57
94
  return Ok(JsonValue::Null);
@@ -76,96 +113,576 @@ fn to_value_typed(
76
113
  ruby_value_type::RUBY_T_FIXNUM | ruby_value_type::RUBY_T_BIGNUM => {
77
114
  convert_integer(ruby, value)
78
115
  }
79
- ruby_value_type::RUBY_T_FLOAT => {
80
- let f = f64::try_convert(value)?;
81
- Number::from_f64(f).map(JsonValue::Number).ok_or_else(|| {
82
- Error::new(
83
- ruby.exception_arg_error(),
84
- "Cannot convert NaN or Infinity to JSON",
85
- )
86
- })
116
+ ruby_value_type::RUBY_T_FLOAT => convert_float(ruby, value),
117
+ ruby_value_type::RUBY_T_STRING => convert_string(ruby, value),
118
+ ruby_value_type::RUBY_T_SYMBOL => convert_symbol(value),
119
+ ruby_value_type::RUBY_T_ARRAY => convert_array(ruby, value, depth),
120
+ ruby_value_type::RUBY_T_HASH => convert_hash(ruby, value, depth),
121
+ ruby_value_type::RUBY_T_DATA if value.is_kind_of(ruby.get_inner(&BIG_DECIMAL_CLASS)) => {
122
+ convert_big_decimal(ruby, value)
87
123
  }
88
- ruby_value_type::RUBY_T_STRING => {
89
- let Some(rstring) = RString::from_value(value) else {
90
- unreachable!("We checked the type tag")
91
- };
92
- // SAFETY: rstring is valid and we're in Ruby VM context
93
- #[allow(unsafe_code)]
94
- let bytes = unsafe { rstring.as_slice() };
95
- match std::str::from_utf8(bytes) {
96
- Ok(s) => Ok(JsonValue::String(s.to_owned())),
97
- Err(_) => Err(Error::new(
98
- ruby.exception_encoding_error(),
99
- "String is not valid UTF-8",
100
- )),
101
- }
124
+ _ => unsupported_type_error(ruby, value),
125
+ }
126
+ }
127
+
128
+ #[inline]
129
+ fn convert_float(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
130
+ let float = f64::try_convert(value)?;
131
+ Number::from_f64(float)
132
+ .map(JsonValue::Number)
133
+ .ok_or_else(|| {
134
+ Error::new(
135
+ ruby.exception_arg_error(),
136
+ "Cannot convert NaN or Infinity to JSON",
137
+ )
138
+ })
139
+ }
140
+
141
+ #[inline]
142
+ fn convert_string(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
143
+ utf8_string_from_value(ruby, value, "String is not valid UTF-8").map(JsonValue::String)
144
+ }
145
+
146
+ #[inline]
147
+ fn convert_symbol(value: Value) -> Result<JsonValue, Error> {
148
+ symbol_name_from_value(value).map(JsonValue::String)
149
+ }
150
+
151
+ fn convert_array(ruby: &Ruby, value: Value, depth: u16) -> Result<JsonValue, Error> {
152
+ if depth >= RECURSION_LIMIT {
153
+ return Err(Error::new(
154
+ ruby.exception_arg_error(),
155
+ format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"),
156
+ ));
157
+ }
158
+ let Some(arr) = RArray::from_value(value) else {
159
+ unreachable!("We checked the type tag")
160
+ };
161
+ let len = arr.len();
162
+ let mut json_arr = Vec::with_capacity(len);
163
+ // Do not use `RArray::as_slice` here: recursive conversion may call
164
+ // Ruby APIs for nested values, and `as_slice` borrows Ruby-managed
165
+ // memory that must not be held across Ruby calls/GC.
166
+ for idx in 0..len {
167
+ let idx = isize::try_from(idx).map_err(|_| {
168
+ Error::new(
169
+ ruby.exception_arg_error(),
170
+ "Array index exceeds supported range",
171
+ )
172
+ })?;
173
+ let item: Value = arr.entry(idx)?;
174
+ json_arr.push(to_value_recursive(ruby, item, depth + 1)?);
175
+ }
176
+ Ok(JsonValue::Array(json_arr))
177
+ }
178
+
179
+ fn convert_hash(ruby: &Ruby, value: Value, depth: u16) -> Result<JsonValue, Error> {
180
+ if depth >= RECURSION_LIMIT {
181
+ return Err(Error::new(
182
+ ruby.exception_arg_error(),
183
+ format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"),
184
+ ));
185
+ }
186
+ let Some(hash) = RHash::from_value(value) else {
187
+ unreachable!("We checked the type tag")
188
+ };
189
+ let mut map = Map::with_capacity(hash.len());
190
+ hash.foreach(|key: Value, val: Value| {
191
+ let key_str = hash_key_to_string(ruby, key)?;
192
+ let json_val = to_value_recursive(ruby, val, depth + 1)?;
193
+ map.insert(key_str, json_val);
194
+ Ok(magnus::r_hash::ForEach::Continue)
195
+ })?;
196
+ Ok(JsonValue::Object(map))
197
+ }
198
+
199
+ #[inline]
200
+ fn unsupported_type_error(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
201
+ let class = value.class();
202
+ #[allow(unsafe_code)]
203
+ let class_name = unsafe { class.name() };
204
+ Err(Error::new(
205
+ ruby.exception_type_error(),
206
+ format!("Unsupported type: '{class_name}'"),
207
+ ))
208
+ }
209
+
210
+ #[derive(Clone, Copy)]
211
+ enum CanonicalErrorKind {
212
+ Argument,
213
+ Type,
214
+ Encoding,
215
+ Runtime,
216
+ }
217
+
218
+ impl CanonicalErrorKind {
219
+ #[inline]
220
+ fn as_tag(self) -> &'static str {
221
+ match self {
222
+ Self::Argument => "argument",
223
+ Self::Type => "type",
224
+ Self::Encoding => "encoding",
225
+ Self::Runtime => "runtime",
102
226
  }
103
- ruby_value_type::RUBY_T_SYMBOL => {
104
- let Some(sym) = Symbol::from_value(value) else {
105
- unreachable!("We checked the type tag")
106
- };
107
- let name = sym.name()?;
108
- Ok(JsonValue::String(name.to_string()))
227
+ }
228
+
229
+ #[inline]
230
+ fn from_tag(tag: &str) -> Option<Self> {
231
+ match tag {
232
+ "argument" => Some(Self::Argument),
233
+ "type" => Some(Self::Type),
234
+ "encoding" => Some(Self::Encoding),
235
+ "runtime" => Some(Self::Runtime),
236
+ _ => None,
237
+ }
238
+ }
239
+
240
+ #[inline]
241
+ fn exception(self, ruby: &Ruby) -> ExceptionClass {
242
+ match self {
243
+ Self::Argument => ruby.exception_arg_error(),
244
+ Self::Type => ruby.exception_type_error(),
245
+ Self::Encoding => ruby.exception_encoding_error(),
246
+ Self::Runtime => ruby.exception_runtime_error(),
247
+ }
248
+ }
249
+ }
250
+
251
+ #[inline]
252
+ fn canonical_error_kind_from_ruby_error(ruby: &Ruby, error: &Error) -> CanonicalErrorKind {
253
+ if error.is_kind_of(ruby.exception_type_error()) {
254
+ CanonicalErrorKind::Type
255
+ } else if error.is_kind_of(ruby.exception_encoding_error()) {
256
+ CanonicalErrorKind::Encoding
257
+ } else if error.is_kind_of(ruby.exception_runtime_error()) {
258
+ CanonicalErrorKind::Runtime
259
+ } else {
260
+ CanonicalErrorKind::Argument
261
+ }
262
+ }
263
+
264
+ #[inline]
265
+ fn ruby_error_message(error: &Error) -> String {
266
+ match error.error_type() {
267
+ ErrorType::Error(_, message) => message.to_string(),
268
+ _ => error.to_string(),
269
+ }
270
+ }
271
+
272
+ #[inline]
273
+ fn encode_canonical_error(kind: CanonicalErrorKind, message: &str) -> String {
274
+ format!("{CANONICAL_ERROR_PREFIX}{}|{message}", kind.as_tag())
275
+ }
276
+
277
+ #[inline]
278
+ fn decode_canonical_error(message: &str) -> Option<(CanonicalErrorKind, &str)> {
279
+ let payload = message.strip_prefix(CANONICAL_ERROR_PREFIX)?;
280
+ let (tag, body) = payload.split_once('|')?;
281
+ Some((CanonicalErrorKind::from_tag(tag)?, body))
282
+ }
283
+
284
+ #[inline]
285
+ fn canonical_serde_error_to_ruby(ruby: &Ruby, error: &serde_json::Error) -> Error {
286
+ let rendered = error.to_string();
287
+ let suffix = format!(" at line {} column {}", error.line(), error.column());
288
+ let without_location = rendered.strip_suffix(&suffix).unwrap_or(&rendered);
289
+ if let Some((kind, message)) = decode_canonical_error(without_location) {
290
+ return Error::new(kind.exception(ruby), message.to_string());
291
+ }
292
+ Error::new(ruby.exception_arg_error(), rendered)
293
+ }
294
+
295
+ #[inline]
296
+ fn ruby_error_to_canonical_serde<S>(ruby: &Ruby, error: &Error) -> S::Error
297
+ where
298
+ S: Serializer,
299
+ {
300
+ let kind = canonical_error_kind_from_ruby_error(ruby, error);
301
+ let message = ruby_error_message(error);
302
+ serde::ser::Error::custom(encode_canonical_error(kind, &message))
303
+ }
304
+
305
+ #[inline]
306
+ fn canonical_serde_error<S>(kind: CanonicalErrorKind, message: &str) -> S::Error
307
+ where
308
+ S: Serializer,
309
+ {
310
+ serde::ser::Error::custom(encode_canonical_error(kind, message))
311
+ }
312
+
313
+ #[inline]
314
+ fn utf8_string_from_rstring(
315
+ ruby: &Ruby,
316
+ rstring: RString,
317
+ error_message: &'static str,
318
+ ) -> Result<String, Error> {
319
+ // SAFETY: rstring is valid and we're in Ruby VM context.
320
+ #[allow(unsafe_code)]
321
+ let bytes = unsafe { rstring.as_slice() };
322
+ std::str::from_utf8(bytes)
323
+ .map(str::to_owned)
324
+ .map_err(|_| Error::new(ruby.exception_encoding_error(), error_message))
325
+ }
326
+
327
+ #[inline]
328
+ fn utf8_string_from_value(
329
+ ruby: &Ruby,
330
+ value: Value,
331
+ error_message: &'static str,
332
+ ) -> Result<String, Error> {
333
+ let Some(rstring) = RString::from_value(value) else {
334
+ unreachable!("We checked the type tag")
335
+ };
336
+ utf8_string_from_rstring(ruby, rstring, error_message)
337
+ }
338
+
339
+ #[inline]
340
+ fn symbol_name_from_value(value: Value) -> Result<String, Error> {
341
+ let Some(sym) = Symbol::from_value(value) else {
342
+ unreachable!("We checked the type tag")
343
+ };
344
+ Ok(sym.name()?.to_string())
345
+ }
346
+
347
+ const MAX_CANONICAL_SCRATCH_POOL_SIZE: usize = 8;
348
+ const MAX_CANONICAL_SCRATCH_CAPACITY: usize = 16_384;
349
+
350
+ struct HashEntry {
351
+ key: String,
352
+ value: Value,
353
+ }
354
+
355
+ struct HashEntryScratch<'a> {
356
+ entries: Vec<HashEntry>,
357
+ pool: &'a RefCell<Vec<Vec<HashEntry>>>,
358
+ }
359
+
360
+ impl<'a> HashEntryScratch<'a> {
361
+ fn with_capacity(pool: &'a RefCell<Vec<Vec<HashEntry>>>, capacity: usize) -> Self {
362
+ let mut entries = pool.borrow_mut().pop().unwrap_or_default();
363
+ if entries.capacity() < capacity {
364
+ entries.reserve(capacity - entries.capacity());
365
+ }
366
+ Self { entries, pool }
367
+ }
368
+
369
+ #[inline]
370
+ fn entries_mut(&mut self) -> &mut Vec<HashEntry> {
371
+ &mut self.entries
372
+ }
373
+
374
+ #[inline]
375
+ fn entries(&self) -> &[HashEntry] {
376
+ &self.entries
377
+ }
378
+ }
379
+
380
+ impl Drop for HashEntryScratch<'_> {
381
+ fn drop(&mut self) {
382
+ self.entries.clear();
383
+ if self.entries.capacity() > MAX_CANONICAL_SCRATCH_CAPACITY {
384
+ return;
385
+ }
386
+ let mut pool = self.pool.borrow_mut();
387
+ if pool.len() < MAX_CANONICAL_SCRATCH_POOL_SIZE {
388
+ pool.push(std::mem::take(&mut self.entries));
389
+ }
390
+ }
391
+ }
392
+
393
+ struct CanonicalRubyValue<'scratch> {
394
+ ruby: &'scratch Ruby,
395
+ value: Value,
396
+ depth: u16,
397
+ scratch_pool: &'scratch RefCell<Vec<Vec<HashEntry>>>,
398
+ }
399
+
400
+ impl<'scratch> CanonicalRubyValue<'scratch> {
401
+ fn new(
402
+ ruby: &'scratch Ruby,
403
+ value: Value,
404
+ depth: u16,
405
+ scratch_pool: &'scratch RefCell<Vec<Vec<HashEntry>>>,
406
+ ) -> Self {
407
+ Self {
408
+ ruby,
409
+ value,
410
+ depth,
411
+ scratch_pool,
412
+ }
413
+ }
414
+ }
415
+
416
+ impl Serialize for CanonicalRubyValue<'_> {
417
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
418
+ where
419
+ S: Serializer,
420
+ {
421
+ if self.value.is_nil() {
422
+ return serializer.serialize_unit();
109
423
  }
110
- ruby_value_type::RUBY_T_ARRAY => {
111
- if depth >= RECURSION_LIMIT {
112
- return Err(Error::new(
113
- ruby.exception_arg_error(),
114
- format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"),
115
- ));
424
+
425
+ // SAFETY: We're reading the type tag of a valid Ruby value
426
+ #[allow(unsafe_code)]
427
+ let value_type = unsafe { RB_TYPE(self.value.as_raw()) };
428
+
429
+ match value_type {
430
+ ruby_value_type::RUBY_T_TRUE => serializer.serialize_bool(true),
431
+ ruby_value_type::RUBY_T_FALSE => serializer.serialize_bool(false),
432
+ ruby_value_type::RUBY_T_FIXNUM | ruby_value_type::RUBY_T_BIGNUM => {
433
+ let number = convert_integer(self.ruby, self.value)
434
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
435
+ number.serialize(serializer)
116
436
  }
117
- let Some(arr) = RArray::from_value(value) else {
118
- unreachable!("We checked the type tag")
119
- };
120
- let len = arr.len();
121
- let mut json_arr = Vec::with_capacity(len);
122
- // Do not use `RArray::as_slice` here: recursive conversion may call
123
- // Ruby APIs for nested values, and `as_slice` borrows Ruby-managed
124
- // memory that must not be held across Ruby calls/GC.
125
- for idx in 0..len {
126
- let idx = isize::try_from(idx).map_err(|_| {
127
- Error::new(
128
- ruby.exception_arg_error(),
129
- "Array index exceeds supported range",
130
- )
131
- })?;
132
- let item: Value = arr.entry(idx)?;
133
- json_arr.push(to_value_recursive(ruby, item, depth + 1)?);
437
+ ruby_value_type::RUBY_T_FLOAT => {
438
+ let number = convert_float_for_canonical(self.ruby, self.value)
439
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
440
+ number.serialize(serializer)
134
441
  }
135
- Ok(JsonValue::Array(json_arr))
136
- }
137
- ruby_value_type::RUBY_T_HASH => {
138
- if depth >= RECURSION_LIMIT {
139
- return Err(Error::new(
140
- ruby.exception_arg_error(),
141
- format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"),
142
- ));
442
+ ruby_value_type::RUBY_T_STRING => {
443
+ let value =
444
+ utf8_string_from_value(self.ruby, self.value, "String is not valid UTF-8")
445
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
446
+ serializer.serialize_str(value.as_str())
447
+ }
448
+ ruby_value_type::RUBY_T_SYMBOL => {
449
+ let name = symbol_name_from_value(self.value)
450
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
451
+ serializer.serialize_str(name.as_str())
452
+ }
453
+ ruby_value_type::RUBY_T_ARRAY => {
454
+ if self.depth >= RECURSION_LIMIT {
455
+ return Err(canonical_serde_error::<S>(
456
+ CanonicalErrorKind::Argument,
457
+ &format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"),
458
+ ));
459
+ }
460
+ let Some(arr) = RArray::from_value(self.value) else {
461
+ unreachable!("We checked the type tag")
462
+ };
463
+ let len = arr.len();
464
+ let mut sequence = serializer.serialize_seq(Some(len))?;
465
+ for idx in 0..len {
466
+ let idx = isize::try_from(idx).map_err(|_| {
467
+ canonical_serde_error::<S>(
468
+ CanonicalErrorKind::Argument,
469
+ "Array index exceeds supported range",
470
+ )
471
+ })?;
472
+ let item: Value = arr
473
+ .entry(idx)
474
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
475
+ sequence.serialize_element(&CanonicalRubyValue::new(
476
+ self.ruby,
477
+ item,
478
+ self.depth + 1,
479
+ self.scratch_pool,
480
+ ))?;
481
+ }
482
+ sequence.end()
483
+ }
484
+ ruby_value_type::RUBY_T_HASH => {
485
+ if self.depth >= RECURSION_LIMIT {
486
+ return Err(canonical_serde_error::<S>(
487
+ CanonicalErrorKind::Argument,
488
+ &format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"),
489
+ ));
490
+ }
491
+ let Some(hash) = RHash::from_value(self.value) else {
492
+ unreachable!("We checked the type tag")
493
+ };
494
+ let len = hash.len();
495
+ let mut scratch = HashEntryScratch::with_capacity(self.scratch_pool, len);
496
+ hash.foreach(|key: Value, value: Value| {
497
+ let key = hash_key_to_string(self.ruby, key)?;
498
+ scratch.entries_mut().push(HashEntry { key, value });
499
+ Ok(magnus::r_hash::ForEach::Continue)
500
+ })
501
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
502
+ let entries = scratch.entries_mut();
503
+ entries.sort_by(|left, right| left.key.as_bytes().cmp(right.key.as_bytes()));
504
+ if entries
505
+ .windows(2)
506
+ .any(|window| window[0].key == window[1].key)
507
+ {
508
+ return Err(canonical_serde_error::<S>(
509
+ CanonicalErrorKind::Type,
510
+ DUPLICATE_CANONICAL_KEY_MESSAGE,
511
+ ));
512
+ }
513
+
514
+ let mut map = serializer.serialize_map(Some(len))?;
515
+ for entry in scratch.entries() {
516
+ map.serialize_entry(
517
+ entry.key.as_str(),
518
+ &CanonicalRubyValue::new(
519
+ self.ruby,
520
+ entry.value,
521
+ self.depth + 1,
522
+ self.scratch_pool,
523
+ ),
524
+ )?;
525
+ }
526
+ map.end()
527
+ }
528
+ ruby_value_type::RUBY_T_DATA
529
+ if self
530
+ .value
531
+ .is_kind_of(self.ruby.get_inner(&BIG_DECIMAL_CLASS)) =>
532
+ {
533
+ let number = convert_big_decimal_for_canonical(self.ruby, self.value)
534
+ .map_err(|error| ruby_error_to_canonical_serde::<S>(self.ruby, &error))?;
535
+ number.serialize(serializer)
536
+ }
537
+ _ => {
538
+ let class = self.value.class();
539
+ #[allow(unsafe_code)]
540
+ let class_name = unsafe { class.name() };
541
+ Err(canonical_serde_error::<S>(
542
+ CanonicalErrorKind::Type,
543
+ &format!("Unsupported type: '{class_name}'"),
544
+ ))
143
545
  }
144
- let Some(hash) = RHash::from_value(value) else {
145
- unreachable!("We checked the type tag")
146
- };
147
- let mut map = Map::with_capacity(hash.len());
148
- hash.foreach(|key: Value, val: Value| {
149
- let key_str = hash_key_to_string(ruby, key)?;
150
- let json_val = to_value_recursive(ruby, val, depth + 1)?;
151
- map.insert(key_str, json_val);
152
- Ok(magnus::r_hash::ForEach::Continue)
153
- })?;
154
- Ok(JsonValue::Object(map))
155
546
  }
156
- ruby_value_type::RUBY_T_DATA if value.is_kind_of(ruby.get_inner(&BIG_DECIMAL_CLASS)) => {
157
- convert_big_decimal(ruby, value)
547
+ }
548
+ }
549
+
550
+ #[inline]
551
+ fn convert_float_for_canonical(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
552
+ let float = f64::try_convert(value)?;
553
+ if !float.is_finite() {
554
+ return Ok(JsonValue::Null);
555
+ }
556
+
557
+ if float == 0.0 {
558
+ return Ok(JsonValue::Number(Number::from(0)));
559
+ }
560
+
561
+ if float.fract() == 0.0 {
562
+ if (0.0..U64_UPPER_EXCLUSIVE_F64).contains(&float) {
563
+ // SAFETY: range check above guarantees a lossless conversion.
564
+ #[allow(unsafe_code)]
565
+ let integer = unsafe { float.to_int_unchecked::<u64>() };
566
+ return Ok(JsonValue::Number(Number::from(integer)));
158
567
  }
159
- _ => {
160
- let class = value.class();
568
+ if (I64_LOWER_INCLUSIVE_F64..I64_UPPER_EXCLUSIVE_F64).contains(&float) {
569
+ // SAFETY: range check above guarantees a lossless conversion.
161
570
  #[allow(unsafe_code)]
162
- let class_name = unsafe { class.name() };
163
- Err(Error::new(
164
- ruby.exception_type_error(),
165
- format!("Unsupported type: '{class_name}'"),
166
- ))
571
+ let integer = unsafe { float.to_int_unchecked::<i64>() };
572
+ return Ok(JsonValue::Number(Number::from(integer)));
573
+ }
574
+ if let Some(integer_text) = integer_text_from_float(float) {
575
+ if let Ok(JsonValue::Number(number)) = serde_json::from_str::<JsonValue>(&integer_text)
576
+ {
577
+ return Ok(JsonValue::Number(number));
578
+ }
579
+ }
580
+ return Err(Error::new(
581
+ ruby.exception_arg_error(),
582
+ "Cannot convert float to JSON",
583
+ ));
584
+ }
585
+
586
+ Number::from_f64(float)
587
+ .map(JsonValue::Number)
588
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "Cannot convert float to JSON"))
589
+ }
590
+
591
+ fn integer_text_from_float(float: f64) -> Option<String> {
592
+ const IEEE754_F64_FRAC_BITS_I32: i32 = 52;
593
+ let bits = float.to_bits();
594
+ let negative = (bits >> 63) != 0;
595
+ let exp_bits = i32::try_from((bits >> IEEE754_F64_FRAC_BITS) & 0x7ff).ok()?;
596
+ let frac_mask = (1_u64 << IEEE754_F64_FRAC_BITS) - 1;
597
+ let frac = bits & frac_mask;
598
+
599
+ let (mantissa, exponent) = if exp_bits == 0 {
600
+ (frac, 1 - IEEE754_F64_EXP_BIAS - IEEE754_F64_FRAC_BITS_I32)
601
+ } else {
602
+ (
603
+ (1_u64 << IEEE754_F64_FRAC_BITS) | frac,
604
+ exp_bits - IEEE754_F64_EXP_BIAS - IEEE754_F64_FRAC_BITS_I32,
605
+ )
606
+ };
607
+
608
+ if mantissa == 0 {
609
+ return Some("0".to_string());
610
+ }
611
+
612
+ if exponent < 0 {
613
+ let shift = exponent.unsigned_abs();
614
+ if shift >= u64::BITS {
615
+ return Some("0".to_string());
616
+ }
617
+ let remainder_mask = (1_u64 << shift) - 1;
618
+ if mantissa & remainder_mask != 0 {
619
+ return None;
620
+ }
621
+ let integer = mantissa >> shift;
622
+ let mut output = String::new();
623
+ if negative {
624
+ output.push('-');
625
+ }
626
+ let _ = write!(&mut output, "{integer}");
627
+ return Some(output);
628
+ }
629
+
630
+ let mut chunks = Vec::new();
631
+ let mut value = mantissa;
632
+ while value > 0 {
633
+ chunks.push((value % DECIMAL_BASE_U64) as u32);
634
+ value /= DECIMAL_BASE_U64;
635
+ }
636
+
637
+ let exponent_u32 = u32::try_from(exponent).ok()?;
638
+ for _ in 0..exponent_u32 {
639
+ let mut carry = 0_u64;
640
+ for chunk in &mut chunks {
641
+ let doubled = u64::from(*chunk) * 2 + carry;
642
+ *chunk = (doubled % DECIMAL_BASE_U64) as u32;
643
+ carry = doubled / DECIMAL_BASE_U64;
167
644
  }
645
+ if carry != 0 {
646
+ chunks.push(u32::try_from(carry).ok()?);
647
+ }
648
+ }
649
+
650
+ let mut output = String::new();
651
+ if negative {
652
+ output.push('-');
168
653
  }
654
+
655
+ let most_significant = chunks.pop()?;
656
+ let _ = write!(&mut output, "{most_significant}");
657
+ for chunk in chunks.iter().rev() {
658
+ let _ = write!(&mut output, "{chunk:0DECIMAL_CHUNK_WIDTH$}");
659
+ }
660
+
661
+ Some(output)
662
+ }
663
+
664
+ #[inline]
665
+ fn convert_big_decimal_for_canonical(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
666
+ let is_finite: bool = value.funcall("finite?", ())?;
667
+ if !is_finite {
668
+ return Ok(JsonValue::Null);
669
+ }
670
+
671
+ let fractional_part: Value = value.funcall("frac", ())?;
672
+ let is_integer: bool = fractional_part.funcall("zero?", ())?;
673
+ if is_integer {
674
+ let integer_value: Value = value.funcall("to_i", ())?;
675
+ return convert_integer(ruby, integer_value);
676
+ }
677
+
678
+ let decimal_text: String = value.funcall("to_s", ("F",))?;
679
+ if let Ok(JsonValue::Number(number)) = serde_json::from_str::<JsonValue>(&decimal_text) {
680
+ return Ok(JsonValue::Number(number));
681
+ }
682
+ Err(Error::new(
683
+ ruby.exception_arg_error(),
684
+ "Cannot convert BigDecimal to JSON",
685
+ ))
169
686
  }
170
687
 
171
688
  /// Convert Ruby BigDecimal to JSON Number while preserving precision.
@@ -216,24 +733,10 @@ fn hash_key_to_string(ruby: &Ruby, key: Value) -> Result<String, Error> {
216
733
 
217
734
  match key_type {
218
735
  ruby_value_type::RUBY_T_STRING => {
219
- if let Some(rstring) = RString::from_value(key) {
220
- // SAFETY: rstring is valid
221
- #[allow(unsafe_code)]
222
- let bytes = unsafe { rstring.as_slice() };
223
- return std::str::from_utf8(bytes)
224
- .map(std::borrow::ToOwned::to_owned)
225
- .map_err(|_| {
226
- Error::new(
227
- ruby.exception_encoding_error(),
228
- "Hash key is not valid UTF-8",
229
- )
230
- });
231
- }
736
+ return utf8_string_from_value(ruby, key, "Hash key is not valid UTF-8");
232
737
  }
233
738
  ruby_value_type::RUBY_T_SYMBOL => {
234
- if let Some(sym) = Symbol::from_value(key) {
235
- return Ok(sym.name()?.to_string());
236
- }
739
+ return symbol_name_from_value(key);
237
740
  }
238
741
  _ => {}
239
742
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jsonschema_rs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.43.0
4
+ version: 0.44.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dygalo