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,431 @@
1
+ //! Crate-backed matching over borrowed Ruby string bytes.
2
+ //! The compatibility adapter translates VM shorthands and absolute anchors;
3
+ //! regex-syntax parses syntax, and regex-automata performs every search.
4
+ use super::*;
5
+ use regex_automata::{
6
+ Anchored, Input, MatchKind,
7
+ dfa::{Automaton, OverlappingState, StartKind, dense},
8
+ meta::{Cache, Regex},
9
+ nfa::thompson,
10
+ };
11
+ use regex_syntax::ast::{Ast, RepetitionKind, RepetitionRange};
12
+
13
+ // Each validator owns its search caches. Compiled regex/DFA data stays shared.
14
+ #[derive(Default)]
15
+ pub struct Caches(std::cell::RefCell<Vec<Option<Box<Cache>>>>);
16
+ impl Caches {
17
+ fn is_match(&self, index: usize, regex: &Regex, bytes: &[u8]) -> bool {
18
+ // Each runner is tied to one engine, whose regex indices are fixed at
19
+ // compilation. Allocate only the caches this validator actually uses.
20
+ let mut caches = self.0.borrow_mut();
21
+ if caches.len() <= index {
22
+ caches.resize_with(index + 1, || None);
23
+ }
24
+ let cache = caches[index].get_or_insert_with(|| Box::new(regex.create_cache()));
25
+ regex
26
+ .search_half_with(cache, &Input::new(bytes).earliest(true))
27
+ .is_some()
28
+ }
29
+ }
30
+ #[unsafe(no_mangle)]
31
+ pub extern "C" fn sn_caches_new() -> *mut Caches {
32
+ Box::into_raw(Box::new(Caches::default()))
33
+ }
34
+ /// # Safety
35
+ /// Caches must be owned, returned by sn_caches_new, and freed exactly once.
36
+ #[unsafe(no_mangle)]
37
+ pub unsafe extern "C" fn sn_caches_free(caches: *mut Caches) {
38
+ drop(unsafe { Box::from_raw(caches) });
39
+ }
40
+ /// # Safety
41
+ /// Caches must be live; call under the GVL or while all Ractors are stopped.
42
+ #[unsafe(no_mangle)]
43
+ pub unsafe extern "C" fn sn_caches_size(caches: *const Caches) -> usize {
44
+ // GC may inspect a stopped runner while its search has borrowed a cache.
45
+ let Ok(caches) = (unsafe { &*caches }).0.try_borrow() else {
46
+ return std::mem::size_of::<Caches>();
47
+ };
48
+ std::mem::size_of::<Caches>()
49
+ + caches.capacity() * std::mem::size_of::<Option<Box<Cache>>>()
50
+ + caches
51
+ .iter()
52
+ .flatten()
53
+ .map(|cache| std::mem::size_of::<Cache>() + cache.memory_usage())
54
+ .sum::<usize>()
55
+ }
56
+
57
+ #[derive(Debug)]
58
+ pub(super) enum Pattern {
59
+ Unsupported,
60
+ Dfa(Box<dense::DFA<Vec<u32>>>),
61
+ Compiled { regex: Regex, index: usize },
62
+ }
63
+
64
+ // Preserve every overlapping match while scanning a key with one automaton.
65
+ // Keep individual matchers for unsupported compilation/search configurations.
66
+ #[derive(Debug)]
67
+ pub(super) struct PatternGroup {
68
+ dfa: dense::DFA<Vec<u32>>,
69
+ all: u64,
70
+ }
71
+ impl PatternGroup {
72
+ pub(super) fn compile(names: &[Value], patterns: &[(Pattern, usize)]) -> Option<Self> {
73
+ if !(2..=64).contains(&patterns.len())
74
+ || !patterns
75
+ .iter()
76
+ .all(|(pattern, _)| matches!(pattern, Pattern::Dfa(_)))
77
+ {
78
+ return None;
79
+ }
80
+ // Existing DFA compilation already proved syntax, encoding, compatibility
81
+ // and full start anchoring. Reuse exactly the same translation.
82
+ let sources: Option<Vec<_>> = names
83
+ .iter()
84
+ .map(|&name| {
85
+ std::str::from_utf8(unsafe { string_bytes(name) })
86
+ .ok()
87
+ .and_then(translate)
88
+ })
89
+ .collect();
90
+ let dfa = dense::Builder::new()
91
+ .configure(
92
+ dense::Config::new()
93
+ .match_kind(MatchKind::All)
94
+ .start_kind(StartKind::Anchored)
95
+ .dfa_size_limit(Some(1 << 20))
96
+ .determinize_size_limit(Some(1 << 22)),
97
+ )
98
+ .thompson(thompson::Config::new().nfa_size_limit(Some(1 << 20)))
99
+ .build_many(&sources?)
100
+ .ok()?;
101
+ let all = u64::MAX >> (64 - patterns.len());
102
+ Some(Self { dfa, all })
103
+ }
104
+ pub(super) fn matches(&self, value: Value) -> Option<u64> {
105
+ let mut data = std::ptr::null();
106
+ let mut len = 0;
107
+ let encoding = unsafe { sn_pattern_view(value, &mut data, &mut len) };
108
+ if encoding == ENCODING_OTHER {
109
+ return None;
110
+ }
111
+ let bytes = unsafe { byte_view(data, len) };
112
+ if encoding != ENCODING_SEVEN_BIT
113
+ && ((encoding == ENCODING_ASCII && !bytes.is_ascii())
114
+ || std::str::from_utf8(bytes).is_err())
115
+ {
116
+ return None;
117
+ }
118
+ let input = Input::new(bytes).anchored(Anchored::Yes);
119
+ let mut state = OverlappingState::start();
120
+ let mut matches = 0;
121
+ loop {
122
+ self.dfa
123
+ .try_search_overlapping_fwd(&input, &mut state)
124
+ .ok()?;
125
+ let Some(found) = state.get_match() else {
126
+ break;
127
+ };
128
+ matches |= 1_u64 << found.pattern().as_usize();
129
+ if matches == self.all {
130
+ break;
131
+ }
132
+ }
133
+ Some(matches)
134
+ }
135
+ pub(super) fn memory_usage(&self) -> usize {
136
+ self.dfa.memory_usage()
137
+ }
138
+ }
139
+
140
+ impl Pattern {
141
+ // Select the crate matcher once for a homogeneous array. The borrowed
142
+ // Ruby slices are used synchronously; classification and encoding are
143
+ // still checked on every element, including non-string instances.
144
+ pub(super) fn eval_items(&self, caches: &Caches, required: i32, values: &[Value]) -> Verdict {
145
+ self.visit_invalid_items(caches, required, values, |_, _| Invalid)
146
+ }
147
+
148
+ pub(super) fn visit_invalid_items(
149
+ &self,
150
+ caches: &Caches,
151
+ required: i32,
152
+ values: &[Value],
153
+ invalid: impl FnMut(usize, Value) -> Verdict,
154
+ ) -> Verdict {
155
+ match self {
156
+ Self::Dfa(dfa) => scan_items(required, values, invalid, |bytes| {
157
+ match dfa.try_search_fwd(&Input::new(bytes).anchored(Anchored::Yes).earliest(true))
158
+ {
159
+ Ok(matched) => verdict(matched.is_some()),
160
+ Err(_) => Unsupported,
161
+ }
162
+ }),
163
+ Self::Compiled { regex, index } => scan_items(required, values, invalid, |bytes| {
164
+ verdict(caches.is_match(*index, regex, bytes))
165
+ }),
166
+ Self::Unsupported => scan_items(required, values, invalid, |_| Unsupported),
167
+ }
168
+ }
169
+
170
+ pub(super) fn memory_usage(&self) -> usize {
171
+ match self {
172
+ Self::Dfa(dfa) => std::mem::size_of_val(&**dfa) + dfa.memory_usage(),
173
+ Self::Compiled { regex, .. } => regex.memory_usage(),
174
+ Self::Unsupported => 0,
175
+ }
176
+ }
177
+
178
+ pub(super) fn compile(value: Value, cache_count: &mut usize) -> Self {
179
+ if unsafe { sn_kind(value) } != TYPE_STRING {
180
+ return Self::Unsupported;
181
+ }
182
+ let mut data = std::ptr::null();
183
+ let mut len = 0;
184
+ let encoding = unsafe { sn_string_view(value, &mut data, &mut len) };
185
+ if encoding == ENCODING_OTHER {
186
+ return Self::Unsupported;
187
+ }
188
+ let bytes = unsafe { std::slice::from_raw_parts(data, len) };
189
+ if encoding == ENCODING_ASCII && !bytes.is_ascii() {
190
+ return Self::Unsupported;
191
+ }
192
+ let Some(source) = std::str::from_utf8(bytes).ok().and_then(translate) else {
193
+ return Self::Unsupported;
194
+ };
195
+ let Ok(ast) = regex_syntax::ast::parse::Parser::new().parse(&source) else {
196
+ return Self::Unsupported;
197
+ };
198
+ if !compatible(&ast) {
199
+ return Self::Unsupported;
200
+ }
201
+ // Use a single immutable DFA for fully start-anchored validity. The
202
+ // meta engine retains its literal prefilters for unanchored searches.
203
+ // Bound both the DFA table and auxiliary determinization memory.
204
+ if let Ok(hir) = regex_syntax::hir::translate::Translator::new().translate(&source, &ast)
205
+ && hir
206
+ .properties()
207
+ .look_set_prefix()
208
+ .contains(regex_syntax::hir::Look::Start)
209
+ && let Ok(dfa) = dense::Builder::new()
210
+ .configure(
211
+ dense::Config::new()
212
+ .match_kind(MatchKind::LeftmostFirst)
213
+ .start_kind(StartKind::Anchored)
214
+ .dfa_size_limit(Some(1 << 16))
215
+ .determinize_size_limit(Some(1 << 18)),
216
+ )
217
+ .thompson(thompson::Config::new().nfa_size_limit(Some(1 << 20)))
218
+ .build(&source)
219
+ {
220
+ return Self::Dfa(Box::new(dfa));
221
+ }
222
+ Regex::builder()
223
+ .configure(
224
+ Regex::config()
225
+ .nfa_size_limit(Some(1 << 20))
226
+ .hybrid_cache_capacity(1 << 20),
227
+ )
228
+ .build(&source)
229
+ .map(|regex| {
230
+ let index = *cache_count;
231
+ *cache_count += 1;
232
+ Self::Compiled { regex, index }
233
+ })
234
+ .unwrap_or(Self::Unsupported)
235
+ }
236
+
237
+ pub(super) fn eval(&self, caches: &Caches, value: Value) -> Verdict {
238
+ if matches!(self, Self::Unsupported) {
239
+ return Unsupported;
240
+ }
241
+ let mut data = std::ptr::null();
242
+ let mut len = 0;
243
+ let encoding = unsafe { sn_pattern_view(value, &mut data, &mut len) };
244
+ if encoding == ENCODING_OTHER {
245
+ return Unsupported;
246
+ }
247
+ let bytes = unsafe { std::slice::from_raw_parts(data, len) };
248
+ self.eval_bytes(caches, bytes, encoding)
249
+ }
250
+
251
+ pub(super) fn eval_bytes(&self, caches: &Caches, bytes: &[u8], encoding: i32) -> Verdict {
252
+ if encoding == ENCODING_OTHER || matches!(self, Self::Unsupported) {
253
+ return Unsupported;
254
+ }
255
+ if encoding != ENCODING_SEVEN_BIT
256
+ && ((encoding == ENCODING_ASCII && !bytes.is_ascii())
257
+ || std::str::from_utf8(bytes).is_err())
258
+ {
259
+ return Unsupported;
260
+ }
261
+ let matched = match self {
262
+ Self::Dfa(dfa) => match dfa
263
+ .try_search_fwd(&Input::new(bytes).anchored(Anchored::Yes).earliest(true))
264
+ {
265
+ Ok(matched) => matched.is_some(),
266
+ Err(_) => return Unsupported,
267
+ },
268
+ Self::Compiled { regex, index } => caches.is_match(*index, regex, bytes),
269
+ Self::Unsupported => return Unsupported,
270
+ };
271
+ if matched { Valid } else { Invalid }
272
+ }
273
+ }
274
+
275
+ fn scan_items(
276
+ required: i32,
277
+ values: &[Value],
278
+ mut invalid: impl FnMut(usize, Value) -> Verdict,
279
+ mut matches: impl FnMut(&[u8]) -> Verdict,
280
+ ) -> Verdict {
281
+ for (index, &value) in values.iter().enumerate() {
282
+ let mut data = std::ptr::null();
283
+ let mut len = 0;
284
+ let encoding = unsafe { sn_pattern_item_view(value, &mut data, &mut len) };
285
+ if encoding == -TYPE_UNSUPPORTED {
286
+ return Unsupported;
287
+ }
288
+ if encoding < 0 {
289
+ if required != 0 {
290
+ let result = invalid(index, value);
291
+ if result != Valid {
292
+ return result;
293
+ }
294
+ }
295
+ continue;
296
+ }
297
+ if encoding == ENCODING_OTHER {
298
+ return Unsupported;
299
+ }
300
+ let bytes = unsafe { std::slice::from_raw_parts(data, len) };
301
+ if encoding != ENCODING_SEVEN_BIT
302
+ && ((encoding == ENCODING_ASCII && !bytes.is_ascii())
303
+ || std::str::from_utf8(bytes).is_err())
304
+ {
305
+ return Unsupported;
306
+ }
307
+ let result = matches(bytes);
308
+ match result {
309
+ Unsupported => return Unsupported,
310
+ Invalid => {
311
+ let result = invalid(index, value);
312
+ if result != Valid {
313
+ return result;
314
+ }
315
+ }
316
+ Valid => {}
317
+ }
318
+ }
319
+ Valid
320
+ }
321
+
322
+ // This is a VM compatibility translation, not a regex parser. Unsupported
323
+ // escapes, class set operations and special groups retain Ruby's behavior.
324
+ fn translate(source: &str) -> Option<String> {
325
+ let mut output = String::with_capacity(source.len());
326
+ let mut characters = source.chars().peekable();
327
+ let mut in_class = false;
328
+ while let Some(character) = characters.next() {
329
+ match character {
330
+ '\\' => {
331
+ let escaped = characters.next()?;
332
+ let class = match escaped {
333
+ 'd' => Some("0-9"),
334
+ 'D' => Some("^0-9"),
335
+ 'w' => Some("A-Za-z0-9_"),
336
+ 'W' => Some("^A-Za-z0-9_"),
337
+ 's' => Some(
338
+ r"\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}",
339
+ ),
340
+ 'S' => Some(
341
+ r"^\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}",
342
+ ),
343
+ _ => None,
344
+ };
345
+ if let Some(class) = class {
346
+ if !in_class {
347
+ output.push('[');
348
+ }
349
+ output.push_str(class);
350
+ if !in_class {
351
+ output.push(']');
352
+ }
353
+ } else if ".^$?+*(){}[]|\\-afnrtv".contains(escaped)
354
+ || (!in_class && matches!(escaped, 'A' | 'z'))
355
+ {
356
+ output.push('\\');
357
+ output.push(escaped);
358
+ } else {
359
+ return None;
360
+ }
361
+ }
362
+ '[' => {
363
+ if in_class {
364
+ return None;
365
+ }
366
+ in_class = true;
367
+ output.push(character);
368
+ }
369
+ ']' => {
370
+ in_class = false;
371
+ output.push(character);
372
+ }
373
+ '&' if in_class => return None,
374
+ '-' | '~' if in_class && characters.peek() == Some(&character) => return None,
375
+ '(' if !in_class && characters.peek() == Some(&'?') => {
376
+ characters.next();
377
+ if characters.next() != Some(':') {
378
+ return None;
379
+ }
380
+ output.push_str("(?:");
381
+ }
382
+ '^' if !in_class => output.push_str(r"\A"),
383
+ '$' if !in_class => output.push_str(r"\z"),
384
+ _ => output.push(character),
385
+ }
386
+ }
387
+ Some(output)
388
+ }
389
+
390
+ fn compatible(ast: &Ast) -> bool {
391
+ match ast {
392
+ Ast::Repetition(repetition) => {
393
+ // Ruby treats adjacent repetition operators as possessive or
394
+ // rejects them, while regex-syntax can parse nested repetitions.
395
+ if matches!(*repetition.ast, Ast::Repetition(_)) {
396
+ return false;
397
+ }
398
+ let bounded = match repetition.op.kind {
399
+ RepetitionKind::Range(
400
+ RepetitionRange::Exactly(n) | RepetitionRange::AtLeast(n),
401
+ ) => n <= 4096,
402
+ RepetitionKind::Range(RepetitionRange::Bounded(_, max)) => max <= 4096,
403
+ _ => true,
404
+ };
405
+ bounded && compatible(&repetition.ast)
406
+ }
407
+ Ast::Group(group) => compatible(&group.ast),
408
+ Ast::Alternation(alternation) => alternation.asts.iter().all(compatible),
409
+ Ast::Concat(concat) => concat.asts.iter().all(compatible),
410
+ Ast::Flags(_) | Ast::ClassUnicode(_) | Ast::ClassPerl(_) => false,
411
+ _ => true,
412
+ }
413
+ }
414
+
415
+ #[cfg(test)]
416
+ mod tests {
417
+ use super::*;
418
+
419
+ #[test]
420
+ fn reports_memory_when_a_search_cache_is_borrowed() {
421
+ let caches = Caches::default();
422
+ let regex = Regex::new("value[0-9]+").unwrap();
423
+ assert!(caches.is_match(0, &regex, b"value1"));
424
+ assert!(unsafe { sn_caches_size(&caches) } > std::mem::size_of::<Caches>());
425
+ let _search = caches.0.borrow_mut();
426
+ assert_eq!(
427
+ unsafe { sn_caches_size(&caches) },
428
+ std::mem::size_of::<Caches>()
429
+ );
430
+ }
431
+ }