schemurai 2.2.3 → 3.0.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -7
  3. data/ext/schemurai_native/Cargo.lock +99 -0
  4. data/ext/schemurai_native/Cargo.toml +21 -0
  5. data/ext/schemurai_native/build.rs +35 -0
  6. data/ext/schemurai_native/extconf.rb +63 -0
  7. data/ext/schemurai_native/glue.c +780 -0
  8. data/ext/schemurai_native/protocol.def +63 -0
  9. data/ext/schemurai_native/protocol.h +10 -0
  10. data/ext/schemurai_native/src/budget.rs +101 -0
  11. data/ext/schemurai_native/src/content.rs +574 -0
  12. data/ext/schemurai_native/src/diagnostics.rs +1135 -0
  13. data/ext/schemurai_native/src/enumeration.rs +154 -0
  14. data/ext/schemurai_native/src/evaluation.rs +219 -0
  15. data/ext/schemurai_native/src/format.rs +357 -0
  16. data/ext/schemurai_native/src/graph.rs +50 -0
  17. data/ext/schemurai_native/src/instance_graph.rs +84 -0
  18. data/ext/schemurai_native/src/lib.rs +3139 -0
  19. data/ext/schemurai_native/src/memo.rs +1149 -0
  20. data/ext/schemurai_native/src/memo_scope.rs +240 -0
  21. data/ext/schemurai_native/src/number.rs +1054 -0
  22. data/ext/schemurai_native/src/pattern.rs +431 -0
  23. data/ext/schemurai_native/src/scope.rs +420 -0
  24. data/ext/schemurai_native/src/structural.rs +86 -0
  25. data/ext/schemurai_native/src/tracking.rs +687 -0
  26. data/lib/schemurai/backend.rb +3 -1
  27. data/lib/schemurai/evaluator.rb +45 -3
  28. data/lib/schemurai/limit_budget.rb +78 -0
  29. data/lib/schemurai/limit_exceeded.rb +18 -0
  30. data/lib/schemurai/limited_evaluator.rb +100 -0
  31. data/lib/schemurai/native/diagnostics.rb +52 -0
  32. data/lib/schemurai/native/evaluator.rb +59 -0
  33. data/lib/schemurai/schema_domain.rb +12 -7
  34. data/lib/schemurai/schema_graph.rb +10 -1
  35. data/lib/schemurai/unique_items.rb +2 -0
  36. data/lib/schemurai/version.rb +1 -1
  37. data/lib/schemurai/vm/compiler.rb +7 -3
  38. data/lib/schemurai/vm/evaluator.rb +50 -3
  39. data/lib/schemurai/vm/limited_evaluator.rb +97 -0
  40. data/lib/schemurai.rb +81 -33
  41. data/sig/schemurai.rbs +21 -13
  42. metadata +36 -6
@@ -0,0 +1,1135 @@
1
+ //! Diagnostics traverse borrowed VALUEs without Ruby allocation. Only after
2
+ //! returning to C are the output records materialized as Ruby objects.
3
+ use super::*;
4
+ use tracking::Tracked;
5
+
6
+ struct Error {
7
+ keyword: &'static str,
8
+ instance_path: String,
9
+ schema_path: String,
10
+ opcode: i32,
11
+ operand: Value,
12
+ instance: Value,
13
+ detail: usize,
14
+ }
15
+ #[derive(Default)]
16
+ pub struct Report {
17
+ errors: Vec<Error>,
18
+ instance_path: String,
19
+ schema_path: String,
20
+ // Explicit failure signal for the current evaluation, independent of the
21
+ // number or rendering of diagnostic records. Child evaluations isolate it.
22
+ failed: bool,
23
+ limits: budget::Limits,
24
+ limit_hit: (u32, usize),
25
+ }
26
+ impl Report {
27
+ fn add(
28
+ &mut self,
29
+ keyword: &'static str,
30
+ source: (i32, Value),
31
+ instance: Value,
32
+ detail: usize,
33
+ ) -> Result<(), Verdict> {
34
+ if self.limits.enabled & 1 != 0 && self.errors.len() >= self.limits.max_errors {
35
+ self.limit_hit = (1, self.errors.len().saturating_add(1));
36
+ return Err(Unsupported);
37
+ }
38
+ self.failed = true;
39
+ let schema_path = if keyword == "falseSchema" {
40
+ self.schema_path.clone()
41
+ } else {
42
+ format!("{}/{keyword}", self.schema_path)
43
+ };
44
+ self.errors.push(Error {
45
+ keyword,
46
+ instance_path: self.instance_path.clone(),
47
+ schema_path,
48
+ opcode: source.0,
49
+ operand: source.1,
50
+ instance,
51
+ detail,
52
+ });
53
+ Ok(())
54
+ }
55
+ fn scoped<T>(
56
+ &mut self,
57
+ segment: &str,
58
+ instance: Option<usize>,
59
+ visit: impl FnOnce(&mut Self) -> Result<T, Verdict>,
60
+ ) -> Result<T, Verdict> {
61
+ use std::fmt::Write;
62
+ let schema_len = self.schema_path.len();
63
+ let instance_len = self.instance_path.len();
64
+ self.schema_path.push('/');
65
+ self.schema_path.push_str(segment);
66
+ if let Some(index) = instance {
67
+ write!(self.instance_path, "/{index}").unwrap();
68
+ }
69
+ let result = visit(self);
70
+ self.schema_path.truncate(schema_len);
71
+ self.instance_path.truncate(instance_len);
72
+ result
73
+ }
74
+ fn property<T>(
75
+ &mut self,
76
+ segment: &str,
77
+ key: Value,
78
+ schema_key: Option<Value>,
79
+ visit: impl FnOnce(&mut Self) -> Result<T, Verdict>,
80
+ ) -> Result<T, Verdict> {
81
+ let name = pointer_name(key)?;
82
+ let segment = if let Some(key) = schema_key {
83
+ format!("{segment}/{}", pointer_name(key)?)
84
+ } else {
85
+ segment.to_owned()
86
+ };
87
+ let instance_len = self.instance_path.len();
88
+ self.instance_path.push('/');
89
+ self.instance_path.push_str(&name);
90
+ let result = self.scoped(&segment, None, visit);
91
+ self.instance_path.truncate(instance_len);
92
+ result
93
+ }
94
+ }
95
+
96
+ fn pointer_name(value: Value) -> Result<String, Verdict> {
97
+ if unsafe { sn_kind(value) } != TYPE_STRING {
98
+ return Err(Unsupported);
99
+ }
100
+ let mut data = std::ptr::null();
101
+ let mut len = 0;
102
+ let encoding = unsafe { sn_string_view(value, &mut data, &mut len) };
103
+ let bytes = unsafe { std::slice::from_raw_parts(data, len) };
104
+ if encoding == ENCODING_OTHER || (encoding == ENCODING_ASCII && !bytes.is_ascii()) {
105
+ return Err(Unsupported);
106
+ }
107
+ let name = std::str::from_utf8(bytes).map_err(|_| Unsupported)?;
108
+ Ok(name.replace('~', "~0").replace('/', "~1"))
109
+ }
110
+
111
+ impl Execution<'_> {
112
+ fn collect<const ANNOTATIONS: bool>(
113
+ &self,
114
+ report: &mut Report,
115
+ node: usize,
116
+ value: Value,
117
+ depth: usize,
118
+ active: scope::Context<'_>,
119
+ ) -> Result<Tracked, Verdict> {
120
+ if depth > MAX_EVALUATION_DEPTH {
121
+ return Err(Unsupported);
122
+ }
123
+ if !self.budget.checkpoint() {
124
+ return Err(Unsupported);
125
+ }
126
+ let kind = unsafe { sn_kind(value) };
127
+ if kind == TYPE_UNSUPPORTED {
128
+ return Err(Unsupported);
129
+ }
130
+ self.collect_kind::<ANNOTATIONS>(report, node, value, depth, active, kind)
131
+ }
132
+ fn collect_kind<const ANNOTATIONS: bool>(
133
+ &self,
134
+ report: &mut Report,
135
+ node: usize,
136
+ value: Value,
137
+ depth: usize,
138
+ active: scope::Context<'_>,
139
+ kind: i32,
140
+ ) -> Result<Tracked, Verdict> {
141
+ // A diagnostic success is established by complete traversal, never by
142
+ // a short-circuit validity result. Keep exported annotations and the
143
+ // collection policy separate; failures retain their path-specific errors.
144
+ if !self.budget.checkpoint() {
145
+ return Err(Unsupported);
146
+ }
147
+ let key = self
148
+ .memo
149
+ .key(self, node, value, depth, active, &self.budget);
150
+ if self.budget.hit.get().0 != 0 {
151
+ return Err(Unsupported);
152
+ }
153
+ if let Some(key) = key
154
+ && let Some(result) = self.memo.diagnostic_success(key, ANNOTATIONS)
155
+ {
156
+ return Ok(result);
157
+ }
158
+ let result = self.collect_uncached::<ANNOTATIONS>(report, node, value, depth, active, kind);
159
+ if let Some(key) = key
160
+ && let Ok(coverage) = &result
161
+ && coverage.status == Valid
162
+ {
163
+ self.memo
164
+ .record_diagnostic_success(key, ANNOTATIONS, coverage);
165
+ }
166
+ result
167
+ }
168
+ fn collect_uncached<const ANNOTATIONS: bool>(
169
+ &self,
170
+ report: &mut Report,
171
+ node: usize,
172
+ value: Value,
173
+ depth: usize,
174
+ active: scope::Context<'_>,
175
+ kind: i32,
176
+ ) -> Result<Tracked, Verdict> {
177
+ if depth > MAX_EVALUATION_DEPTH {
178
+ return Err(Unsupported);
179
+ }
180
+ let frame = scope::ScopeFrame {
181
+ resource: self.metadata[node].resource,
182
+ parent: active.scope,
183
+ primary_binding: active
184
+ .scope
185
+ .and_then(|f| f.primary_binding)
186
+ .or(self.metadata[node].primary_binding),
187
+ };
188
+ let active = self.enter_scope(node, active, &frame);
189
+ let tracked = ANNOTATIONS || self.tracks[node];
190
+ let parent_failed = std::mem::replace(&mut report.failed, false);
191
+ let mut prior = Tracked::new(Valid);
192
+ for (op, &source) in self.nodes[node].iter().zip(&self.error_operands[node]) {
193
+ if !self.budget.checkpoint() {
194
+ return Err(Unsupported);
195
+ }
196
+ match op {
197
+ Op::Unsupported => return Err(Unsupported),
198
+ Op::Boolean(false) => {
199
+ report.add("falseSchema", source, value, 0)?;
200
+ prior = Tracked::new(Invalid);
201
+ }
202
+ Op::Boolean(true) => {}
203
+ Op::Type(mask) => {
204
+ if kind & mask == 0 {
205
+ report.add("type", source, value, 0)?;
206
+ }
207
+ }
208
+ Op::Const(..) | Op::Enum(..) => match self.leaf(op, kind, value, depth) {
209
+ Unsupported => return Err(Unsupported),
210
+ Invalid => report.add(
211
+ if matches!(op, Op::Const(_)) {
212
+ "const"
213
+ } else {
214
+ "enum"
215
+ },
216
+ source,
217
+ value,
218
+ 0,
219
+ )?,
220
+ Valid => {}
221
+ },
222
+ Op::Number(required, rules) => {
223
+ if *required != 0 && kind & required == 0 {
224
+ report.add("type", source, value, 0)?;
225
+ }
226
+ if kind & TYPE_NUMBER != 0 {
227
+ let bits = rules.failures(value)?;
228
+ for (bit, keyword) in [
229
+ (MAXIMUM, "maximum"),
230
+ (MINIMUM, "minimum"),
231
+ (EXCLUSIVE_MAXIMUM, "exclusiveMaximum"),
232
+ (EXCLUSIVE_MINIMUM, "exclusiveMinimum"),
233
+ (MULTIPLE_OF, "multipleOf"),
234
+ ] {
235
+ if !self.budget.checkpoint() {
236
+ return Err(Unsupported);
237
+ }
238
+ if bits & bit != 0 {
239
+ report.add(keyword, source, value, 0)?;
240
+ }
241
+ }
242
+ }
243
+ }
244
+ Op::String(required, rules) => {
245
+ if *required != 0 && kind != TYPE_STRING {
246
+ report.add("type", source, value, 0)?;
247
+ }
248
+ if kind == TYPE_STRING {
249
+ let len = if rules.min.is_some() || rules.max.is_some() {
250
+ unsafe { sn_len(value) }
251
+ } else {
252
+ 0
253
+ };
254
+ if rules.max.is_some_and(|max| len > max) {
255
+ report.add("maxLength", source, value, len)?;
256
+ }
257
+ if rules.min.is_some_and(|min| len < min) {
258
+ report.add("minLength", source, value, len)?;
259
+ }
260
+ if let Some(pattern) = &rules.pattern {
261
+ match pattern.eval(self.caches, value) {
262
+ Unsupported => return Err(Unsupported),
263
+ Invalid => report.add("pattern", source, value, 0)?,
264
+ Valid => {}
265
+ }
266
+ }
267
+ if let Some(format) = &rules.format {
268
+ match format.eval(value) {
269
+ Unsupported => return Err(Unsupported),
270
+ Invalid => report.add("format", source, value, 0)?,
271
+ Valid => {}
272
+ }
273
+ }
274
+ if let Some(content) = &rules.content {
275
+ match content.eval(value) {
276
+ Unsupported => return Err(Unsupported),
277
+ Invalid => report.add(
278
+ if content.base64 {
279
+ "contentEncoding"
280
+ } else {
281
+ "contentMediaType"
282
+ },
283
+ source,
284
+ value,
285
+ 0,
286
+ )?,
287
+ Valid => {}
288
+ }
289
+ }
290
+ }
291
+ }
292
+ Op::Array(required, rules) => {
293
+ if kind == TYPE_ARRAY && !self.budget.checkpoint() {
294
+ return Err(Unsupported);
295
+ }
296
+ if *required != 0 && kind != TYPE_ARRAY {
297
+ report.add("type", source, value, 0)?;
298
+ }
299
+ if kind != TYPE_ARRAY {
300
+ continue;
301
+ }
302
+ let mut coverage = Tracked::new(Valid);
303
+ let len = unsafe { sn_len(value) };
304
+ if rules.max.is_some_and(|max| len > max) {
305
+ report.add("maxItems", source, value, len)?;
306
+ }
307
+ if rules.min.is_some_and(|min| len < min) {
308
+ report.add("minItems", source, value, len)?;
309
+ }
310
+ if rules.unique {
311
+ match unique(value, depth, self.fixnum_flag, &self.budget) {
312
+ Unsupported => return Err(Unsupported),
313
+ Invalid => report.add("uniqueItems", source, value, 0)?,
314
+ Valid => {}
315
+ }
316
+ }
317
+ for (index, &child) in rules.prefix.iter().take(len).enumerate() {
318
+ if !self.budget.checkpoint() {
319
+ return Err(Unsupported);
320
+ }
321
+ if tracked {
322
+ coverage.item(index);
323
+ }
324
+ report.scoped(&format!("prefixItems/{index}"), Some(index), |r| {
325
+ self.collect::<false>(
326
+ r,
327
+ child,
328
+ unsafe { sn_entry(value, index) },
329
+ depth + 1,
330
+ active,
331
+ )
332
+ })?;
333
+ }
334
+ if rules.list {
335
+ for (index, &child) in rules.items.iter().take(len).enumerate() {
336
+ if !self.budget.checkpoint() {
337
+ return Err(Unsupported);
338
+ }
339
+ if tracked {
340
+ coverage.item(index);
341
+ }
342
+ report.scoped(&format!("items/{index}"), Some(index), |r| {
343
+ self.collect::<false>(
344
+ r,
345
+ child,
346
+ unsafe { sn_entry(value, index) },
347
+ depth + 1,
348
+ active,
349
+ )
350
+ })?;
351
+ }
352
+ if let Some(child) = rules.additional {
353
+ for index in rules.items.len()..len {
354
+ if !self.budget.checkpoint() {
355
+ return Err(Unsupported);
356
+ }
357
+ if tracked {
358
+ coverage.item(index);
359
+ }
360
+ report.scoped("additionalItems", Some(index), |r| {
361
+ self.collect::<false>(
362
+ r,
363
+ child,
364
+ unsafe { sn_entry(value, index) },
365
+ depth + 1,
366
+ active,
367
+ )
368
+ })?;
369
+ }
370
+ }
371
+ } else if let Some(&child) = rules.items.first() {
372
+ let primitive = match self.nodes[child].as_slice() {
373
+ [Op::Type(mask)]
374
+ if mask & TYPE_NUMBER != 0 || mask & TYPE_INTEGER != 0 =>
375
+ {
376
+ Some(&[][..])
377
+ }
378
+ [Op::Number(_, rules)] => rules.fast_limits.as_deref(),
379
+ _ => None,
380
+ };
381
+ let values = if len == 0 {
382
+ &[]
383
+ } else {
384
+ unsafe { std::slice::from_raw_parts(sn_array_data(value), len) }
385
+ };
386
+ let pattern = match self.nodes[child].as_slice() {
387
+ [Op::String(required, string)] if depth < MAX_EVALUATION_DEPTH => {
388
+ string.pattern_only().map(|pattern| (*required, pattern))
389
+ }
390
+ _ => None,
391
+ };
392
+ let pattern = pattern.filter(|_| !self.budget.timed());
393
+ let number = match self.nodes[child].as_slice() {
394
+ [Op::Number(required, number)] if depth < MAX_EVALUATION_DEPTH => {
395
+ Some((*required, number))
396
+ }
397
+ _ => None,
398
+ };
399
+ let format = match self.nodes[child].as_slice() {
400
+ [Op::String(required, string)] if depth < MAX_EVALUATION_DEPTH => {
401
+ string.format_only().map(|format| (*required, format))
402
+ }
403
+ _ => None,
404
+ };
405
+ let format = format.filter(|_| !self.budget.timed());
406
+ let content = match self.nodes[child].as_slice() {
407
+ [Op::String(required, string)] if depth < MAX_EVALUATION_DEPTH => {
408
+ string.content_only().map(|content| (*required, content))
409
+ }
410
+ _ => None,
411
+ };
412
+ let content = content.filter(|_| !self.budget.timed());
413
+ if pattern.is_some() || format.is_some() || content.is_some() {
414
+ let start = rules.prefix.len().min(len);
415
+ if tracked {
416
+ for index in start..len {
417
+ if !self.budget.checkpoint() {
418
+ return Err(Unsupported);
419
+ }
420
+ coverage.item(index);
421
+ }
422
+ }
423
+ // The invalid callback is shared, but each scanner retains
424
+ // its statically selected parser and guarded byte views.
425
+ let invalid = |index, item| match report.scoped(
426
+ "items",
427
+ Some(start + index),
428
+ |r| self.collect::<false>(r, child, item, depth + 1, active),
429
+ ) {
430
+ Ok(_) => Valid,
431
+ Err(status) => status,
432
+ };
433
+ let status = match (pattern, format, content) {
434
+ (Some((required, pattern)), _, _) => pattern.visit_invalid_items(
435
+ self.caches,
436
+ required,
437
+ &values[start..],
438
+ invalid,
439
+ ),
440
+ (_, Some((required, format)), _) => {
441
+ format.visit_invalid_items(required, &values[start..], invalid)
442
+ }
443
+ (_, _, Some((required, content))) => {
444
+ content.visit_invalid_items(required, &values[start..], invalid)
445
+ }
446
+ _ => unreachable!("a scalar item scanner was selected"),
447
+ };
448
+ if status != Valid {
449
+ return Err(status);
450
+ }
451
+ } else {
452
+ for (index, &item) in values.iter().enumerate().skip(rules.prefix.len())
453
+ {
454
+ if !self.budget.checkpoint() {
455
+ return Err(Unsupported);
456
+ }
457
+ if tracked {
458
+ coverage.item(index);
459
+ }
460
+ let needs = if let Some(limits) = primitive.filter(|_| {
461
+ item & self.fixnum_flag != 0 && depth < MAX_EVALUATION_DEPTH
462
+ }) {
463
+ let n = if limits.is_empty() {
464
+ 0
465
+ } else {
466
+ immediate_integer(item)
467
+ };
468
+ !limits.iter().all(|&(bit, limit)| match bit {
469
+ MAXIMUM => n <= limit,
470
+ MINIMUM => n >= limit,
471
+ EXCLUSIVE_MAXIMUM => n < limit,
472
+ _ => n > limit,
473
+ })
474
+ } else if let Some((required, number)) = number {
475
+ match number.eval_guarded(required, item) {
476
+ Valid => false,
477
+ Invalid => true,
478
+ Unsupported => return Err(Unsupported),
479
+ }
480
+ } else {
481
+ self.needs_diagnostics(child, item, depth + 1, active)?
482
+ };
483
+ if !needs {
484
+ continue;
485
+ }
486
+ report.scoped("items", Some(index), |r| {
487
+ self.collect::<false>(r, child, item, depth + 1, active)
488
+ })?;
489
+ }
490
+ }
491
+ }
492
+ if let Some(child) = rules.contains {
493
+ let values = if len == 0 {
494
+ &[]
495
+ } else {
496
+ unsafe { std::slice::from_raw_parts(sn_array_data(value), len) }
497
+ };
498
+ let matches = evaluation::scan_contains(
499
+ len,
500
+ |index| match self.needs_diagnostics(
501
+ child,
502
+ values[index],
503
+ depth + 1,
504
+ active,
505
+ ) {
506
+ Ok(needs) => verdict(!needs),
507
+ Err(status) => status,
508
+ },
509
+ |index| {
510
+ if tracked {
511
+ coverage.item(index);
512
+ }
513
+ },
514
+ )?;
515
+ if bounds(matches, Some(rules.min_contains), rules.max_contains) == Invalid
516
+ {
517
+ report.add("contains", source, value, matches)?;
518
+ }
519
+ }
520
+ if let Some(child) = rules
521
+ .unevaluated
522
+ .filter(|_| prior.item_count() < len && coverage.item_count() < len)
523
+ {
524
+ for index in 0..len {
525
+ if !self.budget.checkpoint() {
526
+ return Err(Unsupported);
527
+ }
528
+ if prior.contains_item(index) || coverage.contains_item(index) {
529
+ continue;
530
+ }
531
+ report.scoped("unevaluatedItems", Some(index), |r| {
532
+ self.collect::<false>(
533
+ r,
534
+ child,
535
+ unsafe { sn_entry(value, index) },
536
+ depth + 1,
537
+ active,
538
+ )
539
+ })?;
540
+ coverage.item(index);
541
+ }
542
+ }
543
+ if tracked {
544
+ prior.merge_diagnostics(coverage)?;
545
+ }
546
+ }
547
+ Op::Object(required, rules) => {
548
+ if kind == TYPE_OBJECT && !self.budget.checkpoint() {
549
+ return Err(Unsupported);
550
+ }
551
+ if *required != 0 && kind != TYPE_OBJECT {
552
+ report.add("type", source, value, 0)?;
553
+ }
554
+ if kind != TYPE_OBJECT {
555
+ continue;
556
+ }
557
+ let mut coverage = Tracked::new(Valid);
558
+ let len = unsafe { sn_len(value) };
559
+ if rules.max.is_some_and(|max| len > max) {
560
+ report.add("maxProperties", source, value, len)?;
561
+ }
562
+ if rules.min.is_some_and(|min| len < min) {
563
+ report.add("minProperties", source, value, len)?;
564
+ }
565
+ for &name in &rules.required_order {
566
+ if !self.budget.checkpoint() {
567
+ return Err(Unsupported);
568
+ }
569
+ if missing(value, name, self.undefined) {
570
+ report.add("required", source, name, 0)?;
571
+ }
572
+ }
573
+ let mut context = ObjectDiagnostics {
574
+ engine: self,
575
+ report,
576
+ rules,
577
+ depth: depth + 1,
578
+ active,
579
+ names: false,
580
+ unevaluated: false,
581
+ coverage: &mut coverage,
582
+ prior: &prior,
583
+ tracked,
584
+ };
585
+ if (!rules.properties.is_empty()
586
+ || !rules.patterns.is_empty()
587
+ || rules.additional.is_some())
588
+ && unsafe {
589
+ sn_hash_each(
590
+ value,
591
+ (&mut context as *mut ObjectDiagnostics).cast(),
592
+ object_diagnostic,
593
+ )
594
+ } != VERDICT_VALID
595
+ {
596
+ return Err(Unsupported);
597
+ }
598
+ if rules.names.is_some() {
599
+ context.names = true;
600
+ if unsafe {
601
+ sn_hash_each(
602
+ value,
603
+ (&mut context as *mut ObjectDiagnostics).cast(),
604
+ object_diagnostic,
605
+ )
606
+ } != VERDICT_VALID
607
+ {
608
+ return Err(Unsupported);
609
+ }
610
+ }
611
+ for dependency in &rules.diagnostic_dependencies {
612
+ if !self.budget.checkpoint() {
613
+ return Err(Unsupported);
614
+ }
615
+ match dependency {
616
+ DiagnosticDependency::Required(name, names, keyword) => {
617
+ if missing(value, *name, self.undefined) {
618
+ continue;
619
+ }
620
+ for &required in names {
621
+ if !self.budget.checkpoint() {
622
+ return Err(Unsupported);
623
+ }
624
+ if missing(value, required, self.undefined) {
625
+ report.add(keyword, (source.0, *name), required, 0)?;
626
+ }
627
+ }
628
+ }
629
+ DiagnosticDependency::Schema(name, child, keyword) => {
630
+ if missing(value, *name, self.undefined) {
631
+ continue;
632
+ }
633
+ if tracked {
634
+ let result = report.scoped(
635
+ &format!("{keyword}/{}", pointer_name(*name)?),
636
+ None,
637
+ |r| {
638
+ self.collect::<true>(
639
+ r,
640
+ *child,
641
+ value,
642
+ depth + 1,
643
+ active,
644
+ )
645
+ },
646
+ )?;
647
+ if result.status == Valid {
648
+ coverage.merge(result);
649
+ }
650
+ } else if self.needs_diagnostics(
651
+ *child,
652
+ value,
653
+ depth + 1,
654
+ active,
655
+ )? {
656
+ report.scoped(
657
+ &format!("{keyword}/{}", pointer_name(*name)?),
658
+ None,
659
+ |r| {
660
+ self.collect::<false>(
661
+ r,
662
+ *child,
663
+ value,
664
+ depth + 1,
665
+ active,
666
+ )
667
+ },
668
+ )?;
669
+ }
670
+ }
671
+ }
672
+ }
673
+ if rules.unevaluated.is_some()
674
+ && prior.property_count() < len
675
+ && coverage.property_count() < len
676
+ {
677
+ let mut context = ObjectDiagnostics {
678
+ engine: self,
679
+ report,
680
+ rules,
681
+ depth: depth + 1,
682
+ active,
683
+ names: false,
684
+ unevaluated: true,
685
+ coverage: &mut coverage,
686
+ prior: &prior,
687
+ tracked,
688
+ };
689
+ if unsafe {
690
+ sn_hash_each(
691
+ value,
692
+ (&mut context as *mut ObjectDiagnostics).cast(),
693
+ object_diagnostic,
694
+ )
695
+ } != VERDICT_VALID
696
+ {
697
+ return Err(Unsupported);
698
+ }
699
+ }
700
+ if tracked {
701
+ prior.merge_diagnostics(coverage)?;
702
+ }
703
+ }
704
+ Op::All(nodes) => {
705
+ let mut coverage = Tracked::new(Valid);
706
+ for (index, &child) in nodes.iter().enumerate() {
707
+ if !self.budget.checkpoint() {
708
+ return Err(Unsupported);
709
+ }
710
+ let result = report.scoped(&format!("allOf/{index}"), None, |r| {
711
+ if tracked {
712
+ self.collect_kind::<true>(r, child, value, depth + 1, active, kind)
713
+ } else {
714
+ self.collect_kind::<false>(r, child, value, depth + 1, active, kind)
715
+ }
716
+ })?;
717
+ if tracked {
718
+ coverage.merge_diagnostics(result)?;
719
+ }
720
+ }
721
+ if tracked {
722
+ prior.merge_diagnostics(coverage)?;
723
+ }
724
+ }
725
+ Op::Any(nodes) | Op::One(nodes) => {
726
+ let branch_kind = if matches!(op, Op::Any(_)) {
727
+ evaluation::BranchKind::Any
728
+ } else {
729
+ evaluation::BranchKind::One
730
+ };
731
+ // Complete scanning preserves exact oneOf counts and VM
732
+ // exceptions from branches after an anyOf success.
733
+ let result = if tracked {
734
+ evaluation::scan_branches(
735
+ nodes,
736
+ evaluation::BranchPolicy::Complete,
737
+ |child| self.eval_tracked_kind(child, value, depth + 1, active, kind),
738
+ )?
739
+ } else {
740
+ let result = evaluation::scan_branches(
741
+ nodes,
742
+ evaluation::BranchPolicy::Complete,
743
+ |child| {
744
+ self.eval_with_kind(child, value, depth + 1, active, Some(kind))
745
+ },
746
+ )?;
747
+ evaluation::BranchMatches {
748
+ matches: result.matches,
749
+ coverage: Tracked::new(Valid),
750
+ }
751
+ };
752
+ if !branch_kind.accepts(result.matches) {
753
+ report.add(branch_kind.keyword(), source, value, result.matches)?;
754
+ } else if tracked {
755
+ prior.merge_diagnostics(result.coverage)?;
756
+ }
757
+ }
758
+ Op::Not(child) => {
759
+ match self.eval_with_kind(*child, value, depth + 1, active, Some(kind)) {
760
+ Unsupported => return Err(Unsupported),
761
+ Valid => report.add("not", source, value, 0)?,
762
+ Invalid => {}
763
+ }
764
+ }
765
+ Op::Conditional(condition, yes, no) => {
766
+ let condition = if tracked {
767
+ self.eval_tracked_kind(*condition, value, depth + 1, active, kind)
768
+ } else {
769
+ Tracked::new(self.eval_with_kind(
770
+ *condition,
771
+ value,
772
+ depth + 1,
773
+ active,
774
+ Some(kind),
775
+ ))
776
+ };
777
+ let mut coverage = Tracked::new(Valid);
778
+ let (child, segment) = match condition.status {
779
+ Unsupported => return Err(Unsupported),
780
+ Valid => {
781
+ if tracked {
782
+ coverage.merge(condition);
783
+ }
784
+ (yes, "then")
785
+ }
786
+ Invalid => (no, "else"),
787
+ };
788
+ if let Some(child) = child {
789
+ let result = report.scoped(segment, None, |r| {
790
+ if tracked {
791
+ self.collect_kind::<true>(r, *child, value, depth + 1, active, kind)
792
+ } else {
793
+ self.collect_kind::<false>(
794
+ r,
795
+ *child,
796
+ value,
797
+ depth + 1,
798
+ active,
799
+ kind,
800
+ )
801
+ }
802
+ })?;
803
+ if tracked {
804
+ coverage.merge_diagnostics(result)?;
805
+ }
806
+ }
807
+ if tracked {
808
+ prior.merge_diagnostics(coverage)?;
809
+ }
810
+ }
811
+ Op::Ref(target) => {
812
+ if RefFrame::contains(active.active, node, value) {
813
+ continue;
814
+ }
815
+ let frame = RefFrame {
816
+ source: node,
817
+ value,
818
+ parent: active.active,
819
+ };
820
+ let keyword = match source.0 {
821
+ OP_RECURSIVE_REF => "$recursiveRef",
822
+ OP_DYNAMIC_REF => "$dynamicRef",
823
+ _ => "$ref",
824
+ };
825
+ let target = self.reference_target(*target, active);
826
+ let active = scope::Context {
827
+ active: Some(&frame),
828
+ ..active
829
+ };
830
+ let result = report.scoped(keyword, None, |r| {
831
+ if tracked {
832
+ self.collect_kind::<true>(r, target, value, depth + 1, active, kind)
833
+ } else {
834
+ self.collect_kind::<false>(r, target, value, depth + 1, active, kind)
835
+ }
836
+ })?;
837
+ if tracked {
838
+ prior.merge_diagnostics(result)?;
839
+ }
840
+ }
841
+ }
842
+ }
843
+ let failed = report.failed;
844
+ report.failed |= parent_failed;
845
+ // Diagnostic coverage keeps local attempted locations for subsequent
846
+ // unevaluated checks; only a successful evaluation exports annotations.
847
+ Ok(if failed { Tracked::new(Invalid) } else { prior })
848
+ }
849
+
850
+ fn needs_diagnostics(
851
+ &self,
852
+ node: usize,
853
+ value: Value,
854
+ depth: usize,
855
+ active: scope::Context<'_>,
856
+ ) -> Result<bool, Verdict> {
857
+ if !self.budget.checkpoint() {
858
+ return Err(Unsupported);
859
+ }
860
+ let status = match self.nodes[node].as_slice() {
861
+ [Op::Number(required, rules)] if depth <= MAX_EVALUATION_DEPTH => {
862
+ rules.eval_guarded(*required, value)
863
+ }
864
+ [op @ (Op::Type(_) | Op::Boolean(_) | Op::String(..) | Op::Const(_) | Op::Enum(_))]
865
+ if depth <= MAX_EVALUATION_DEPTH =>
866
+ {
867
+ let kind = if value & self.fixnum_flag != 0 {
868
+ TYPE_INTEGRAL_NUMBER
869
+ } else {
870
+ unsafe { sn_kind(value) }
871
+ };
872
+ if kind == TYPE_UNSUPPORTED {
873
+ Unsupported
874
+ } else {
875
+ self.leaf(op, kind, value, depth)
876
+ }
877
+ }
878
+ _ => self.eval(node, value, depth, active),
879
+ };
880
+ match status {
881
+ Valid => Ok(false),
882
+ Invalid => Ok(true),
883
+ Unsupported => Err(Unsupported),
884
+ }
885
+ }
886
+ }
887
+
888
+ struct ObjectDiagnostics<'a, 'b, 'c> {
889
+ engine: &'a Execution<'a>,
890
+ report: &'b mut Report,
891
+ rules: &'a Object,
892
+ depth: usize,
893
+ active: scope::Context<'c>,
894
+ names: bool,
895
+ unevaluated: bool,
896
+ coverage: &'b mut Tracked,
897
+ prior: &'b Tracked,
898
+ tracked: bool,
899
+ }
900
+ unsafe extern "C" fn object_diagnostic(context: *mut c_void, key: Value, value: Value) -> i32 {
901
+ catch_unwind(AssertUnwindSafe(|| {
902
+ let c = unsafe { &mut *context.cast::<ObjectDiagnostics<'_, '_, '_>>() };
903
+ if !c.engine.budget.checkpoint() {
904
+ return Unsupported as i32;
905
+ }
906
+ let visit = |report: &mut Report, child: usize, item: Value, segment: &str, schema_key| {
907
+ if c.engine.needs_diagnostics(child, item, c.depth, c.active)? {
908
+ report.property(segment, key, schema_key, |r| {
909
+ c.engine.collect::<false>(r, child, item, c.depth, c.active)
910
+ })?;
911
+ }
912
+ Ok::<_, Verdict>(())
913
+ };
914
+ let result = (|| {
915
+ if unsafe { sn_kind(key) } != TYPE_STRING {
916
+ return Err(Unsupported);
917
+ }
918
+ if c.names {
919
+ return visit(c.report, c.rules.names.unwrap(), key, "propertyNames", None);
920
+ }
921
+ if c.unevaluated {
922
+ if c.prior.contains_property(c.engine, key)
923
+ || c.coverage.contains_property(c.engine, key)
924
+ {
925
+ return Ok(());
926
+ }
927
+ visit(
928
+ c.report,
929
+ c.rules.unevaluated.unwrap(),
930
+ value,
931
+ "unevaluatedProperties",
932
+ None,
933
+ )?;
934
+ c.coverage.property(c.engine, key);
935
+ return Ok(());
936
+ }
937
+ let evaluated = c.rules.visit_value_rules::<true>(
938
+ c.engine.caches,
939
+ &c.engine.budget,
940
+ key,
941
+ None,
942
+ |segment, name, child| visit(c.report, child, value, segment, name),
943
+ )?;
944
+ if c.tracked && evaluated {
945
+ c.coverage.property(c.engine, key);
946
+ }
947
+ Ok(())
948
+ })();
949
+ match result {
950
+ Ok(()) => VERDICT_VALID,
951
+ Err(_) => Unsupported as i32,
952
+ }
953
+ }))
954
+ .unwrap_or(Unsupported as i32)
955
+ }
956
+
957
+ #[repr(C)]
958
+ pub struct Record {
959
+ keyword: *const u8,
960
+ keyword_len: usize,
961
+ instance_path: *const u8,
962
+ instance_len: usize,
963
+ schema_path: *const u8,
964
+ schema_len: usize,
965
+ opcode: i32,
966
+ operand: Value,
967
+ instance: Value,
968
+ detail: usize,
969
+ }
970
+ /// # Safety
971
+ /// Engine/status must be live under the GVL; root must be in bounds and
972
+ /// instance live. Caches must be null or live, validator-owned and not in use.
973
+ #[unsafe(no_mangle)]
974
+ pub unsafe extern "C" fn sn_diagnose(
975
+ engine: *const Engine,
976
+ caches: *const pattern::Caches,
977
+ root: usize,
978
+ instance: Value,
979
+ status: *mut i32,
980
+ ) -> *mut Report {
981
+ let result = catch_unwind(AssertUnwindSafe(|| {
982
+ let engine = unsafe { &*engine };
983
+ let local = pattern::Caches::default();
984
+ let caches = unsafe { caches.as_ref() }.unwrap_or(&local);
985
+ let execution = Execution {
986
+ engine,
987
+ caches,
988
+ memo: memo::Memo::default(),
989
+ budget: budget::Budget::default(),
990
+ };
991
+ let mut report = Report::default();
992
+ execution.collect::<false>(&mut report, root, instance, 0, scope::Context::default())?;
993
+ Ok::<_, Verdict>(Box::into_raw(Box::new(report)))
994
+ }));
995
+ match result {
996
+ Ok(Ok(report)) => {
997
+ unsafe { *status = VERDICT_VALID };
998
+ report
999
+ }
1000
+ other => {
1001
+ unsafe {
1002
+ *status = if other.is_err() {
1003
+ STATUS_PANIC
1004
+ } else {
1005
+ VERDICT_UNSUPPORTED
1006
+ }
1007
+ };
1008
+ std::ptr::null_mut()
1009
+ }
1010
+ }
1011
+ }
1012
+ /// # Safety
1013
+ /// Same requirements as sn_diagnose; all limit/output pointers must be live.
1014
+ #[unsafe(no_mangle)]
1015
+ pub unsafe extern "C" fn sn_diagnose_with_limits(
1016
+ engine: *const Engine,
1017
+ caches: *const pattern::Caches,
1018
+ root: usize,
1019
+ instance: Value,
1020
+ status: *mut i32,
1021
+ limits: *const budget::Limits,
1022
+ outcome: *mut budget::Outcome,
1023
+ ) -> *mut Report {
1024
+ let result = catch_unwind(AssertUnwindSafe(|| {
1025
+ let local = pattern::Caches::default();
1026
+ let caches = unsafe { caches.as_ref() }.unwrap_or(&local);
1027
+ let limits = unsafe { *limits };
1028
+ let execution = Execution {
1029
+ engine: unsafe { &*engine },
1030
+ caches,
1031
+ memo: memo::Memo::default(),
1032
+ budget: budget::Budget::new(limits),
1033
+ };
1034
+ let mut report = Report {
1035
+ limits,
1036
+ ..Report::default()
1037
+ };
1038
+ let result = if execution.budget.check_deadline() {
1039
+ execution.collect::<false>(&mut report, root, instance, 0, scope::Context::default())
1040
+ } else {
1041
+ Err(Unsupported)
1042
+ };
1043
+ let result = if execution.budget.check_deadline() {
1044
+ result
1045
+ } else {
1046
+ Err(Unsupported)
1047
+ };
1048
+ let hit = if report.limit_hit.0 != 0 {
1049
+ report.limit_hit
1050
+ } else {
1051
+ execution.budget.hit.get()
1052
+ };
1053
+ unsafe {
1054
+ ((*outcome).kind, (*outcome).attempted) = hit;
1055
+ }
1056
+ result.map(|_| Box::into_raw(Box::new(report)))
1057
+ }));
1058
+ match result {
1059
+ Ok(Ok(report)) => {
1060
+ unsafe { *status = VERDICT_VALID };
1061
+ report
1062
+ }
1063
+ other => {
1064
+ unsafe {
1065
+ *status = if other.is_err() {
1066
+ STATUS_PANIC
1067
+ } else {
1068
+ VERDICT_UNSUPPORTED
1069
+ }
1070
+ };
1071
+ std::ptr::null_mut()
1072
+ }
1073
+ }
1074
+ }
1075
+ /// # Safety
1076
+ /// Report must be live under the GVL.
1077
+ #[unsafe(no_mangle)]
1078
+ pub unsafe extern "C" fn sn_report_len(report: *const Report) -> usize {
1079
+ unsafe { &*report }.errors.len()
1080
+ }
1081
+ /// # Safety
1082
+ /// Report must be live under the GVL.
1083
+ #[unsafe(no_mangle)]
1084
+ pub unsafe extern "C" fn sn_report_size(report: *const Report) -> usize {
1085
+ let r = unsafe { &*report };
1086
+ std::mem::size_of::<Report>()
1087
+ + r.errors.capacity() * std::mem::size_of::<Error>()
1088
+ + r.instance_path.capacity()
1089
+ + r.schema_path.capacity()
1090
+ + r.errors
1091
+ .iter()
1092
+ .map(|e| e.instance_path.capacity() + e.schema_path.capacity())
1093
+ .sum::<usize>()
1094
+ }
1095
+ /// # Safety
1096
+ /// Report must be live, index in bounds, and record a writable pointer.
1097
+ #[unsafe(no_mangle)]
1098
+ pub unsafe extern "C" fn sn_report_record(
1099
+ report: *const Report,
1100
+ index: usize,
1101
+ record: *mut Record,
1102
+ ) {
1103
+ let e = &unsafe { &*report }.errors[index];
1104
+ unsafe {
1105
+ *record = Record {
1106
+ keyword: e.keyword.as_ptr(),
1107
+ keyword_len: e.keyword.len(),
1108
+ instance_path: e.instance_path.as_ptr(),
1109
+ instance_len: e.instance_path.len(),
1110
+ schema_path: e.schema_path.as_ptr(),
1111
+ schema_len: e.schema_path.len(),
1112
+ opcode: e.opcode,
1113
+ operand: e.operand,
1114
+ instance: e.instance,
1115
+ detail: e.detail,
1116
+ }
1117
+ };
1118
+ }
1119
+ /// # Safety
1120
+ /// Report must be live; call only from its synchronous Ruby GC mark callback.
1121
+ #[unsafe(no_mangle)]
1122
+ pub unsafe extern "C" fn sn_report_mark(report: *const Report) {
1123
+ for error in &unsafe { &*report }.errors {
1124
+ unsafe {
1125
+ sn_pin(error.operand);
1126
+ sn_pin(error.instance);
1127
+ }
1128
+ }
1129
+ }
1130
+ /// # Safety
1131
+ /// Report must be owned, returned by sn_diagnose, and freed exactly once.
1132
+ #[unsafe(no_mangle)]
1133
+ pub unsafe extern "C" fn sn_report_free(report: *mut Report) {
1134
+ drop(unsafe { Box::from_raw(report) });
1135
+ }