dry-validation-rust 0.1.0.pre5

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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +74 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE.md +28 -0
  5. data/README.md +459 -0
  6. data/docs/ARCHITECTURE.md +256 -0
  7. data/docs/COMPATIBILITY.md +198 -0
  8. data/docs/FEASIBILITY.md +207 -0
  9. data/docs/SUPPORT_MATRIX.md +66 -0
  10. data/docs/VERIFICATION.md +128 -0
  11. data/dry-validation-rust.gemspec +58 -0
  12. data/ext/dry_validation_rust/Cargo.lock +809 -0
  13. data/ext/dry_validation_rust/Cargo.toml +44 -0
  14. data/ext/dry_validation_rust/benches/coercion.rs +77 -0
  15. data/ext/dry_validation_rust/benches/full_schema.rs +189 -0
  16. data/ext/dry_validation_rust/benches/plan_compile.rs +37 -0
  17. data/ext/dry_validation_rust/benches/predicates.rs +105 -0
  18. data/ext/dry_validation_rust/extconf.rb +29 -0
  19. data/ext/dry_validation_rust/src/coercion.rs +515 -0
  20. data/ext/dry_validation_rust/src/engine.rs +416 -0
  21. data/ext/dry_validation_rust/src/error.rs +82 -0
  22. data/ext/dry_validation_rust/src/extract_primitive.rs +23 -0
  23. data/ext/dry_validation_rust/src/generated_predicates.rs +33 -0
  24. data/ext/dry_validation_rust/src/lib.rs +228 -0
  25. data/ext/dry_validation_rust/src/plan.rs +611 -0
  26. data/ext/dry_validation_rust/src/predicates.rs +449 -0
  27. data/ext/dry_validation_rust/src/ruby_bridge.rs +78 -0
  28. data/lib/dry/schema.rb +6 -0
  29. data/lib/dry/validation/rust/block_keyword_parameters.rb +20 -0
  30. data/lib/dry/validation/rust/config.rb +74 -0
  31. data/lib/dry/validation/rust/contract/result.rb +180 -0
  32. data/lib/dry/validation/rust/contract/values.rb +73 -0
  33. data/lib/dry/validation/rust/contract.rb +400 -0
  34. data/lib/dry/validation/rust/errors.rb +14 -0
  35. data/lib/dry/validation/rust/evaluator.rb +295 -0
  36. data/lib/dry/validation/rust/failures.rb +57 -0
  37. data/lib/dry/validation/rust/generated_predicates.rb +14 -0
  38. data/lib/dry/validation/rust/macros.rb +45 -0
  39. data/lib/dry/validation/rust/message.rb +41 -0
  40. data/lib/dry/validation/rust/message_backend.rb +115 -0
  41. data/lib/dry/validation/rust/message_set.rb +159 -0
  42. data/lib/dry/validation/rust/native.rb +25 -0
  43. data/lib/dry/validation/rust/path.rb +65 -0
  44. data/lib/dry/validation/rust/path_trie.rb +57 -0
  45. data/lib/dry/validation/rust/result.rb +3 -0
  46. data/lib/dry/validation/rust/rule.rb +62 -0
  47. data/lib/dry/validation/rust/schema/dsl.rb +76 -0
  48. data/lib/dry/validation/rust/schema/field_builder.rb +156 -0
  49. data/lib/dry/validation/rust/schema/field_definition.rb +99 -0
  50. data/lib/dry/validation/rust/schema/predicate_block.rb +56 -0
  51. data/lib/dry/validation/rust/schema/processor_hooks.rb +46 -0
  52. data/lib/dry/validation/rust/schema/result.rb +67 -0
  53. data/lib/dry/validation/rust/schema/ruby_type_processor.rb +44 -0
  54. data/lib/dry/validation/rust/schema.rb +323 -0
  55. data/lib/dry/validation/rust/values.rb +3 -0
  56. data/lib/dry/validation/rust/version.rb +10 -0
  57. data/lib/dry/validation/rust.rb +55 -0
  58. data/lib/dry/validation.rb +66 -0
  59. data/lib/dry-schema.rb +3 -0
  60. data/lib/dry-validation.rb +3 -0
  61. data/lib/dry_validation_rust.rb +3 -0
  62. data/predicates.yml +67 -0
  63. data/rust-toolchain.toml +9 -0
  64. metadata +260 -0
@@ -0,0 +1,515 @@
1
+ use crate::{plan::Mode, ruby_bridge::RuntimeClasses};
2
+ use bigdecimal::BigDecimal;
3
+ use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Timelike};
4
+ use magnus::{
5
+ prelude::*,
6
+ value::{Qfalse, Qtrue},
7
+ Float, Integer, RArray, RHash, RString, Ruby, Symbol, Value,
8
+ };
9
+
10
+ pub(crate) fn coerce(
11
+ ruby: &Ruby,
12
+ classes: &RuntimeClasses,
13
+ mode: Mode,
14
+ kind: &str,
15
+ value: Value,
16
+ ) -> Result<Option<Value>, magnus::Error> {
17
+ if type_matches(ruby, classes, kind, value) {
18
+ return Ok(Some(value));
19
+ }
20
+ if !allows_literal_coercion(mode) {
21
+ return Ok(None);
22
+ }
23
+
24
+ let Some(string) = RString::from_value(value) else {
25
+ return Ok(None);
26
+ };
27
+ let Ok(source) = string.to_string() else {
28
+ return Ok(None);
29
+ };
30
+ let converted = match kind {
31
+ // Signed 64-bit literals, including Ruby's common underscore and base
32
+ // forms, avoid a Ruby callback. Delegate Bignums and unusual syntax so
33
+ // Ruby retains its arbitrary-precision semantics.
34
+ "integer" => fast_integer(ruby, &source).or_else(|| {
35
+ ruby.module_kernel()
36
+ .funcall::<_, _, Value>("Integer", (source.as_str(), 10))
37
+ .ok()
38
+ }),
39
+ "float" if non_finite_literal(&source) => None,
40
+ // Finite decimal literals (including scientific notation) avoid a
41
+ // Ruby callback. Delegate every other spelling so Ruby retains its
42
+ // syntax and non-finite result semantics.
43
+ "float" => fast_float(&source)
44
+ .map(|value| ruby.float_from_f64(value).as_value())
45
+ .or_else(|| {
46
+ ruby.module_kernel()
47
+ .funcall::<_, _, Value>("Float", (source.as_str(),))
48
+ .ok()
49
+ }),
50
+ "bool" | "true" | "false" => params_boolean(&source).map(|value| {
51
+ if value {
52
+ ruby.qtrue().as_value()
53
+ } else {
54
+ ruby.qfalse().as_value()
55
+ }
56
+ }),
57
+ "symbol" => Some(ruby.to_symbol(&source).as_value()),
58
+ "date" => fast_date(ruby, classes, &source).or_else(|| {
59
+ classes
60
+ .date(ruby)
61
+ .expect("Date class is loaded for date fields")
62
+ .funcall::<_, _, Value>("iso8601", (source.as_str(),))
63
+ .ok()
64
+ }),
65
+ "date_time" => fast_date_time(ruby, classes, &source).or_else(|| {
66
+ classes
67
+ .date_time(ruby)
68
+ .expect("DateTime class is loaded for date_time fields")
69
+ .funcall::<_, _, Value>("iso8601", (source.as_str(),))
70
+ .ok()
71
+ }),
72
+ "time" => fast_time(ruby, classes, &source).or_else(|| {
73
+ classes
74
+ .time(ruby)
75
+ .expect("Time class is loaded for time fields")
76
+ .funcall::<_, _, Value>("parse", (source.as_str(),))
77
+ .ok()
78
+ }),
79
+ "decimal" => fast_decimal(ruby, &source).or_else(|| {
80
+ ruby.module_kernel()
81
+ .funcall::<_, _, Value>("BigDecimal", (source.as_str(),))
82
+ .ok()
83
+ .filter(|decimal| {
84
+ decimal
85
+ .funcall::<_, _, bool>("finite?", ())
86
+ .unwrap_or(false)
87
+ })
88
+ }),
89
+ _ => None,
90
+ };
91
+ Ok(converted)
92
+ }
93
+
94
+ fn fast_integer(ruby: &Ruby, source: &str) -> Option<Value> {
95
+ let (negative, digits) = split_sign(source)?;
96
+ let (radix, digits) = if let Some(digits) = digits.strip_prefix("0x") {
97
+ (16, digits)
98
+ } else if let Some(digits) = digits.strip_prefix("0b") {
99
+ (2, digits)
100
+ } else if let Some(digits) = digits.strip_prefix("0o") {
101
+ (8, digits)
102
+ } else {
103
+ (10, digits)
104
+ };
105
+ let digits = normalized_digits(digits, radix)?;
106
+ let value = if negative {
107
+ let magnitude = u64::from_str_radix(&digits, radix).ok()?;
108
+ if magnitude == i64::MAX as u64 + 1 {
109
+ i64::MIN
110
+ } else {
111
+ -i64::try_from(magnitude).ok()?
112
+ }
113
+ } else {
114
+ i64::from_str_radix(&digits, radix).ok()?
115
+ };
116
+ Some(ruby.integer_from_i64(value).as_value())
117
+ }
118
+
119
+ fn fast_float(source: &str) -> Option<f64> {
120
+ let normalized = normalize_decimal(source)?;
121
+ normalized
122
+ .parse::<f64>()
123
+ .ok()
124
+ .filter(|value| value.is_finite())
125
+ }
126
+
127
+ fn fast_date(ruby: &Ruby, classes: &RuntimeClasses, source: &str) -> Option<Value> {
128
+ let date = NaiveDate::parse_from_str(source, "%Y-%m-%d").ok()?;
129
+ classes
130
+ .date(ruby)?
131
+ .funcall::<_, _, Value>("new", (date.year(), date.month(), date.day()))
132
+ .ok()
133
+ }
134
+
135
+ fn fast_date_time(ruby: &Ruby, classes: &RuntimeClasses, source: &str) -> Option<Value> {
136
+ let date_time = parse_iso_date_time(source).filter(|value| value.nanosecond() == 0)?;
137
+ classes
138
+ .date_time(ruby)?
139
+ .funcall::<_, _, Value>(
140
+ "civil",
141
+ (
142
+ date_time.year(),
143
+ date_time.month(),
144
+ date_time.day(),
145
+ date_time.hour(),
146
+ date_time.minute(),
147
+ seconds(&date_time),
148
+ offset_string(date_time.offset()),
149
+ ),
150
+ )
151
+ .ok()
152
+ }
153
+
154
+ fn fast_time(ruby: &Ruby, classes: &RuntimeClasses, source: &str) -> Option<Value> {
155
+ let date_time = DateTime::parse_from_rfc3339(source)
156
+ .ok()
157
+ .filter(|value| value.nanosecond() == 0)?;
158
+ let time = classes.time(ruby)?;
159
+ if date_time.offset().local_minus_utc() == 0 {
160
+ time.funcall::<_, _, Value>(
161
+ "utc",
162
+ (
163
+ date_time.year(),
164
+ date_time.month(),
165
+ date_time.day(),
166
+ date_time.hour(),
167
+ date_time.minute(),
168
+ seconds(&date_time),
169
+ ),
170
+ )
171
+ .ok()
172
+ } else {
173
+ time.funcall::<_, _, Value>(
174
+ "new",
175
+ (
176
+ date_time.year(),
177
+ date_time.month(),
178
+ date_time.day(),
179
+ date_time.hour(),
180
+ date_time.minute(),
181
+ seconds(&date_time),
182
+ offset_string(date_time.offset()),
183
+ ),
184
+ )
185
+ .ok()
186
+ }
187
+ }
188
+
189
+ fn fast_decimal(ruby: &Ruby, source: &str) -> Option<Value> {
190
+ let normalized = normalize_decimal(source)?;
191
+ normalized.parse::<BigDecimal>().ok()?;
192
+ ruby.module_kernel()
193
+ .funcall::<_, _, Value>("BigDecimal", (source,))
194
+ .ok()
195
+ }
196
+
197
+ fn split_sign(source: &str) -> Option<(bool, &str)> {
198
+ match source.as_bytes().first() {
199
+ Some(b'-') => Some((true, &source[1..])),
200
+ Some(b'+') => Some((false, &source[1..])),
201
+ Some(_) => Some((false, source)),
202
+ None => None,
203
+ }
204
+ }
205
+
206
+ fn normalized_digits(source: &str, radix: u32) -> Option<String> {
207
+ (!source.is_empty()
208
+ && underscores_are_digit_separators(source, radix)
209
+ && source
210
+ .chars()
211
+ .all(|character| character.is_digit(radix) || character == '_'))
212
+ .then(|| source.replace('_', ""))
213
+ .filter(|digits| !digits.is_empty())
214
+ }
215
+
216
+ fn normalize_decimal(source: &str) -> Option<String> {
217
+ let (negative, source) = split_sign(source)?;
218
+ let source = source.strip_prefix('+').unwrap_or(source);
219
+ if !underscores_are_digit_separators(source, 10) {
220
+ return None;
221
+ }
222
+ let normalized = source.replace('_', "");
223
+ let valid = normalized
224
+ .bytes()
225
+ .all(|byte| byte.is_ascii_digit() || matches!(byte, b'.' | b'e' | b'E' | b'+' | b'-'));
226
+ (valid && normalized.bytes().any(|byte| byte.is_ascii_digit())).then(|| {
227
+ if negative {
228
+ format!("-{normalized}")
229
+ } else {
230
+ normalized
231
+ }
232
+ })
233
+ }
234
+
235
+ fn underscores_are_digit_separators(source: &str, radix: u32) -> bool {
236
+ let bytes = source.as_bytes();
237
+ !bytes.iter().enumerate().any(|(index, byte)| {
238
+ *byte == b'_'
239
+ && (index == 0
240
+ || index + 1 == bytes.len()
241
+ || !char::from(bytes[index - 1]).is_digit(radix)
242
+ || !char::from(bytes[index + 1]).is_digit(radix))
243
+ })
244
+ }
245
+
246
+ fn parse_iso_date_time(source: &str) -> Option<DateTime<FixedOffset>> {
247
+ DateTime::parse_from_rfc3339(source).ok().or_else(|| {
248
+ NaiveDateTime::parse_from_str(source, "%Y-%m-%dT%H:%M:%S%.f")
249
+ .ok()
250
+ .and_then(|value| {
251
+ FixedOffset::east_opt(0)?
252
+ .from_local_datetime(&value)
253
+ .single()
254
+ })
255
+ })
256
+ }
257
+
258
+ fn seconds(date_time: &DateTime<FixedOffset>) -> f64 {
259
+ f64::from(date_time.second()) + f64::from(date_time.nanosecond()) / 1_000_000_000.0
260
+ }
261
+
262
+ fn offset_string(offset: &FixedOffset) -> String {
263
+ let seconds = offset.local_minus_utc();
264
+ format!(
265
+ "{}{hours:02}:{minutes:02}",
266
+ if seconds < 0 { '-' } else { '+' },
267
+ hours = seconds.unsigned_abs() / 3600,
268
+ minutes = (seconds.unsigned_abs() % 3600) / 60
269
+ )
270
+ }
271
+
272
+ fn params_boolean(source: &str) -> Option<bool> {
273
+ if source.eq_ignore_ascii_case("true")
274
+ || source == "1"
275
+ || source.eq_ignore_ascii_case("on")
276
+ || source.eq_ignore_ascii_case("t")
277
+ || source.eq_ignore_ascii_case("yes")
278
+ || source.eq_ignore_ascii_case("y")
279
+ {
280
+ Some(true)
281
+ } else if source.eq_ignore_ascii_case("false")
282
+ || source == "0"
283
+ || source.eq_ignore_ascii_case("off")
284
+ || source.eq_ignore_ascii_case("f")
285
+ || source.eq_ignore_ascii_case("no")
286
+ || source.eq_ignore_ascii_case("n")
287
+ {
288
+ Some(false)
289
+ } else {
290
+ None
291
+ }
292
+ }
293
+
294
+ fn allows_literal_coercion(mode: Mode) -> bool {
295
+ mode == Mode::Params
296
+ }
297
+
298
+ pub(crate) fn null_if_empty_nullable_param(
299
+ ruby: &Ruby,
300
+ mode: Mode,
301
+ nullable: bool,
302
+ value: Value,
303
+ ) -> Option<Value> {
304
+ (nullable
305
+ && mode == Mode::Params
306
+ && RString::from_value(value).is_some_and(|string| string.is_empty()))
307
+ .then(|| ruby.qnil().as_value())
308
+ }
309
+
310
+ fn non_finite_literal(source: &str) -> bool {
311
+ let source = source.trim();
312
+ source.eq_ignore_ascii_case("infinity")
313
+ || source.eq_ignore_ascii_case("+infinity")
314
+ || source.eq_ignore_ascii_case("-infinity")
315
+ || source.eq_ignore_ascii_case("inf")
316
+ || source.eq_ignore_ascii_case("+inf")
317
+ || source.eq_ignore_ascii_case("-inf")
318
+ || source.eq_ignore_ascii_case("nan")
319
+ || source.eq_ignore_ascii_case("+nan")
320
+ || source.eq_ignore_ascii_case("-nan")
321
+ }
322
+
323
+ pub(crate) fn type_matches(
324
+ ruby: &Ruby,
325
+ classes: &RuntimeClasses,
326
+ kind: &str,
327
+ value: Value,
328
+ ) -> bool {
329
+ match kind {
330
+ "any" => true,
331
+ "nil" => value.is_nil(),
332
+ "bool" => Qtrue::from_value(value).is_some() || Qfalse::from_value(value).is_some(),
333
+ "true" => Qtrue::from_value(value).is_some(),
334
+ "false" => Qfalse::from_value(value).is_some(),
335
+ "integer" => Integer::from_value(value).is_some(),
336
+ "float" => Float::from_value(value).is_some(),
337
+ "decimal" => classes
338
+ .big_decimal(ruby)
339
+ .is_some_and(|class| value.is_kind_of(class)),
340
+ "string" => RString::from_value(value).is_some(),
341
+ "symbol" => Symbol::from_value(value).is_some(),
342
+ "array" => RArray::from_value(value).is_some(),
343
+ "hash" => RHash::from_value(value).is_some(),
344
+ "date" => {
345
+ classes
346
+ .date(ruby)
347
+ .is_some_and(|class| value.is_kind_of(class))
348
+ && !classes
349
+ .date_time(ruby)
350
+ .is_some_and(|class| value.is_kind_of(class))
351
+ }
352
+ "date_time" => classes
353
+ .date_time(ruby)
354
+ .is_some_and(|class| value.is_kind_of(class)),
355
+ "time" => classes
356
+ .time(ruby)
357
+ .is_some_and(|class| value.is_kind_of(class)),
358
+ _ => {
359
+ let _ = ruby;
360
+ false
361
+ }
362
+ }
363
+ }
364
+
365
+ #[inline]
366
+ pub(crate) fn empty_value(value: Value) -> bool {
367
+ if let Some(string) = RString::from_value(value) {
368
+ string.is_empty()
369
+ } else if let Some(array) = RArray::from_value(value) {
370
+ array.is_empty()
371
+ } else if let Some(hash) = RHash::from_value(value) {
372
+ hash.is_empty()
373
+ } else {
374
+ false
375
+ }
376
+ }
377
+
378
+ #[cfg(test)]
379
+ pub(crate) mod tests {
380
+ use super::*;
381
+ use crate::plan::Mode;
382
+ use magnus::Error;
383
+
384
+ fn runtime_classes(ruby: &Ruby) -> Result<RuntimeClasses, Error> {
385
+ ruby.eval::<Value>("require 'date'; require 'bigdecimal'")?;
386
+ RuntimeClasses::all(ruby)
387
+ }
388
+
389
+ #[test]
390
+ fn params_boolean_accepts_true_and_false_boundary_tokens() {
391
+ for (source, expected) in [
392
+ ("true", Some(true)),
393
+ ("false", Some(false)),
394
+ ("1", Some(true)),
395
+ ("0", Some(false)),
396
+ ("yes", Some(true)),
397
+ ("", None),
398
+ ("TRUE", Some(true)),
399
+ ] {
400
+ assert_eq!(params_boolean(source), expected, "token {source:?}");
401
+ }
402
+ }
403
+
404
+ #[test]
405
+ fn params_float_rejects_non_finite_literals_without_ruby() {
406
+ for source in ["Infinity", "-Infinity", "NaN", "+inf", " -NaN "] {
407
+ assert!(non_finite_literal(source), "literal {source:?}");
408
+ }
409
+
410
+ for source in ["0.0", "-0.0", "1e308", "1e309", ""] {
411
+ assert!(!non_finite_literal(source), "literal {source:?}");
412
+ }
413
+ }
414
+
415
+ #[test]
416
+ fn fast_float_handles_common_cases() {
417
+ assert_eq!(fast_float("3.14"), "3.14".parse().ok());
418
+ assert_eq!(fast_float("-0.5"), Some(-0.5));
419
+ assert_eq!(fast_float("42"), Some(42.0));
420
+ assert_eq!(fast_float("+1_000.5"), Some(1_000.5));
421
+ assert_eq!(fast_float("1.2e3"), Some(1_200.0));
422
+
423
+ for source in ["", "-", ".", "1__000.5", "1e", "Infinity", "1..0"] {
424
+ assert_eq!(
425
+ fast_float(source),
426
+ None,
427
+ "{source:?} should delegate to Ruby"
428
+ );
429
+ }
430
+ }
431
+
432
+ #[test]
433
+ fn only_params_mode_enables_literal_coercion() {
434
+ assert!(allows_literal_coercion(Mode::Params));
435
+ assert!(!allows_literal_coercion(Mode::Json));
436
+ assert!(!allows_literal_coercion(Mode::Schema));
437
+ }
438
+
439
+ pub(crate) fn params_coercion_handles_native_boundary_edge_cases(
440
+ ruby: &Ruby,
441
+ ) -> Result<(), Error> {
442
+ let classes = runtime_classes(ruby)?;
443
+
444
+ for (source, expected) in [
445
+ ("42", 42),
446
+ ("-42", -42),
447
+ ("+42", 42),
448
+ ("9223372036854775807", i64::MAX),
449
+ ("-9223372036854775808", i64::MIN),
450
+ ] {
451
+ let value = fast_integer(ruby, source).expect("canonical integer should use fast path");
452
+ assert_eq!(Integer::from_value(value).unwrap().to_i64()?, expected);
453
+ }
454
+
455
+ for source in ["", " 42", "1__000", "9223372036854775808"] {
456
+ assert!(
457
+ fast_integer(ruby, source).is_none(),
458
+ "{source:?} should delegate to Ruby"
459
+ );
460
+ }
461
+
462
+ for (source, expected) in [("1_000", 1_000), ("0x10", 16), ("0b101", 5), ("0o17", 15)] {
463
+ let value =
464
+ fast_integer(ruby, source).expect("common integer syntax should use fast path");
465
+ assert_eq!(Integer::from_value(value).unwrap().to_i64()?, expected);
466
+ }
467
+
468
+ assert!(parse_iso_date_time("2026-07-12T10:00:00+03:00").is_some());
469
+ assert!(parse_iso_date_time("2026-02-30T10:00:00Z").is_none());
470
+ assert!("12.50".parse::<BigDecimal>().is_ok());
471
+ assert!("1e999".parse::<BigDecimal>().is_ok());
472
+
473
+ assert!(ruby.eval::<Value>("Date.iso8601('2026-02-30')").is_err());
474
+ assert!(coerce(
475
+ ruby,
476
+ &classes,
477
+ Mode::Params,
478
+ "date",
479
+ ruby.str_new("2026-02-30").as_value(),
480
+ )?
481
+ .is_none());
482
+
483
+ for source in ["Infinity", "-Infinity", "NaN"] {
484
+ assert!(coerce(
485
+ ruby,
486
+ &classes,
487
+ Mode::Params,
488
+ "decimal",
489
+ ruby.str_new(source).as_value(),
490
+ )?
491
+ .is_none());
492
+ }
493
+
494
+ let empty = ruby.str_new("").as_value();
495
+ assert!(
496
+ null_if_empty_nullable_param(ruby, Mode::Params, true, empty)
497
+ .expect("nullable params empty string should normalize")
498
+ .is_nil()
499
+ );
500
+ assert!(null_if_empty_nullable_param(ruby, Mode::Json, true, empty).is_none());
501
+
502
+ let source = "роль/админ?!";
503
+ let value = coerce(
504
+ ruby,
505
+ &classes,
506
+ Mode::Params,
507
+ "symbol",
508
+ ruby.str_new(source).as_value(),
509
+ )?
510
+ .expect("symbol source should coerce");
511
+ assert_eq!(Symbol::from_value(value).unwrap().name()?.as_ref(), source);
512
+
513
+ Ok(())
514
+ }
515
+ }