parsanol 1.3.14-arm-linux → 1.3.16-arm-linux

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.
@@ -0,0 +1,1140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require "parsanol/atoms/can_flatten"
5
+
6
+ module Parsanol
7
+ # Bytecode VM for the pure-Ruby parse path.
8
+ #
9
+ # Compiles an atom tree once into a flat integer program and executes it
10
+ # with a single dispatch loop. Terminals record packed span Integers
11
+ # ((pos << 20) | len) instead of allocating Slices; composite values
12
+ # mirror the interpreter's tagged arrays exactly. A bottom-up
13
+ # materialization pass turns spans into Slices and Named markers into
14
+ # their eagerly-flattened hashes, so output is byte-identical to the
15
+ # tree interpreter (mode: :ruby).
16
+ #
17
+ # Design notes: TODO.perf/6-ruby-vm.md
18
+ module VM
19
+ HALT = 0
20
+ STR = 1
21
+ RE = 2
22
+ ANY = 3
23
+ SEQ_BEGIN = 4
24
+ SEQ_END = 5
25
+ CHOICE = 6
26
+ POPBT = 7
27
+ JMP = 8
28
+ NAME_END = 9
29
+ CALL = 10
30
+ RET = 11
31
+ LOOK_POS = 12
32
+ LOOK_POS_END = 13
33
+ LOOK_NEG = 14
34
+ LOOK_NEG_END = 15
35
+ DROP = 16
36
+ FAIL = 17
37
+ REP_INIT = 18
38
+ REP_TEST = 19
39
+ REP_STEP = 20
40
+ REP_EXIT = 21
41
+ MAYBE_RE = 22
42
+ MAYBE_STR = 23
43
+ RUN_RE = 24
44
+ RUN_STR = 25
45
+ SEQ_SIMPLE = 26
46
+
47
+ K_ALT = 0
48
+ K_REP = 1
49
+ K_LOOK_FAIL = 2
50
+ K_NEG = 3
51
+
52
+ SEQ_MARK = Object.new.freeze
53
+
54
+ # Deferred Named wrapper; materialization produces
55
+ # { name => flatten(inner, named: true) } like Named#apply.
56
+ NamedValue = Struct.new(:name, :value)
57
+
58
+ SINGLE_CLASS_RE = /\A(?:\^)?(?:\[[^\[\]]*\]|\\[dDwWsShH]|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|[^\[\\\]|()?*+{}\^])\z/
59
+
60
+ CHAR_WIDTH = Array.new(256, 0)
61
+ (0...128).each { |b| CHAR_WIDTH[b] = 1 }
62
+ (0xC2..0xDF).each { |b| CHAR_WIDTH[b] = 2 }
63
+ (0xE0..0xEF).each { |b| CHAR_WIDTH[b] = 3 }
64
+ (0xF0..0xF4).each { |b| CHAR_WIDTH[b] = 4 }
65
+
66
+ MAX_PACK_LEN = (1 << 20) - 1
67
+ FRAME_STRIDE = 4 # count, end_pos, rbase, min
68
+ BT_STRIDE = 6 # pc, pos, rlen, cbase, clen, kind
69
+ # Returned (as the sole value) when the VM cannot complete a parse
70
+ # for internal reasons — callers should fall back to the interpreter
71
+ # and may skip the VM for this grammar afterwards. A clean
72
+ # [false, nil] means the input genuinely does not parse.
73
+ BAIL = Object.new.freeze
74
+ # Step budget exhausted on the naive pass — internal to #run, which
75
+ # retries once with packrat memoization before giving up.
76
+ BUDGET = Object.new.freeze
77
+ # Memo marker for a subroutine currently being evaluated at a given
78
+ # position. Re-entry (left recursion) fails that path instead of
79
+ # looping forever.
80
+ PENDING = Object.new.freeze
81
+
82
+ STEP_BUDGET_FACTOR = 200
83
+ STEP_BUDGET_FIXED = 10_000
84
+ MEMO_BUDGET_FACTOR = 4_096
85
+ MEMO_BUDGET_CELLS_FACTOR = 16
86
+
87
+ class << self
88
+ @programs = {}
89
+
90
+ # Cached compile keyed by root-atom object identity. Atoms are
91
+ # effectively immutable once constructed; false caches grammars the
92
+ # VM cannot handle so we do not re-walk them on every parse.
93
+ #
94
+ # A grammar whose VM run failed once (unsupported backtracking shape
95
+ # or step budget) is marked :fallback so later parses skip the VM
96
+ # attempt entirely — the interpreter result is identical.
97
+ def program_for(atom)
98
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
99
+ key = root.object_id
100
+ cache = (@programs ||= {})
101
+ cached = cache[key]
102
+ return nil if cached == :fallback
103
+ return cached if key?(cache, key)
104
+
105
+ cache[key] = compile(atom)
106
+ end
107
+
108
+ # Marks a grammar as VM-incompatible after a runtime failure.
109
+ def disable_for!(atom)
110
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
111
+ (@programs ||= {})[root.object_id] = :fallback # rubocop:disable Lint/HashCompareByIdentity -- object_id keys avoid holding strong references to grammar atoms
112
+ end
113
+
114
+ def key?(cache, key) # rubocop:disable Naming/PredicateMethod -- mirrors Hash#key?
115
+ cache.key?(key)
116
+ end
117
+
118
+ def clear_program_cache
119
+ @programs&.clear
120
+ @heavy&.clear
121
+ end
122
+
123
+ # Compiles the grammar rooted at +atom+. Returns the flat program
124
+ # Array, or nil when the grammar uses unsupported atoms.
125
+ def compile(atom)
126
+ program = compile_with_inline(atom, true)
127
+ # Inlining duplicates every non-recursive rule body at each
128
+ # reference site; grammars with many cross-referencing rules
129
+ # explode past the program cap even though the atom tree is
130
+ # modest. Recompiling with inlining off (each rule a CALL/RET
131
+ # subroutine) keeps the program O(atoms) at a small per-rule
132
+ # dispatch cost — far better than refusing the grammar outright.
133
+ return program unless program == :oversize
134
+
135
+ compile_with_inline(atom, false)
136
+ end
137
+
138
+ def compile_with_inline(atom, inline)
139
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
140
+ compiler = Compiler.new(inline: inline)
141
+ return nil unless compiler.compile_atom(root, true)
142
+
143
+ # HALT terminates the MAIN program; subroutines follow it so the
144
+ # root CALL's return address can never collide with a subroutine.
145
+ compiler.ops << [HALT, nil, nil, nil]
146
+ return nil unless compiler.append_subroutines
147
+
148
+ compiler.to_program
149
+ end
150
+
151
+ # Executes a compiled program. Returns [true, value] on success;
152
+ # [false, nil] on failure or budget exhaustion — callers fall back
153
+ # to the interpreter, which reproduces exact diagnostics.
154
+ #
155
+ # Heavy-backtracking grammars get a second chance: the naive pass
156
+ # runs without memoization (zero overhead on the happy path), and
157
+ # only if it blows the step budget does a memoized pass run. The
158
+ # memo converts repeated rule+position work into single entries, so
159
+ # grammars that the interpreter needed (slow) memoization for now
160
+ # run at VM speed.
161
+ def run(program, input, consume_all)
162
+ executor = Executor.new(program, input, consume_all)
163
+ result = executor.execute
164
+ if result.equal?(BUDGET)
165
+ executor = Executor.new(program, input, consume_all, memoize: true)
166
+ result = executor.execute
167
+ result = BAIL if result.equal?(BUDGET)
168
+ end
169
+ result
170
+ end
171
+
172
+ # Grammar-aware entry used by Base#parse. Grammars that have shown
173
+ # heavy-backtracking behavior (budget bust, or a success that
174
+ # burned the step-density threshold) memoize from the first
175
+ # instruction on subsequent parses, skipping the doomed naive
176
+ # pass; a heavy success is retried memoized in the same call so
177
+ # the VM keeps the grammar instead of surrendering it to the
178
+ # interpreter.
179
+ def run_for(atom, program, input, consume_all)
180
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
181
+ id = root.object_id
182
+ heavy = (@heavy ||= {})
183
+ if heavy[id]
184
+ result = Executor.new(program, input, consume_all,
185
+ memoize: true).execute
186
+ return BAIL if result.equal?(BUDGET)
187
+
188
+ return result
189
+ end
190
+
191
+ result = Executor.new(program, input, consume_all).execute
192
+ if result.equal?(BUDGET) ||
193
+ (result.is_a?(Array) && result.first == :heavy)
194
+ heavy[id] = true
195
+ memo_result = Executor.new(program, input, consume_all,
196
+ memoize: true).execute
197
+ # A heavy naive success already answered; the memo pass only
198
+ # proves the grammar stays VM-viable (BUDGET would bail to the
199
+ # interpreter). When the naive pass never answered, the memo
200
+ # pass's outcome is the result.
201
+ return BAIL if memo_result.equal?(BUDGET)
202
+
203
+ result = memo_result if result.equal?(BUDGET)
204
+ end
205
+ result
206
+ end
207
+
208
+ # Converts the VM value tree into the interpreter's value tree.
209
+ def materialize(value, input)
210
+ case value
211
+ when Integer
212
+ pos = value >> 20
213
+ Slice.new(pos, input.byteslice(pos, value & MAX_PACK_LEN), input)
214
+ when NamedValue
215
+ { value.name => Mat.flatten(materialize(value.value, input), true) }
216
+ when Array
217
+ value.map { |v| materialize(v, input) }
218
+ when Hash
219
+ value.transform_values { |v| materialize(v, input) }
220
+ else
221
+ value
222
+ end
223
+ end
224
+ end
225
+
226
+ module Mat
227
+ extend Parsanol::Atoms::CanFlatten
228
+ end
229
+ private_constant :Mat
230
+
231
+ # Compiles atoms into a flat stride-4 program. Entity bodies compile
232
+ # as subroutines, keyed by [body object_id, consume_all] so spine
233
+ # sites (consume_all=true) and inner sites stay semantically distinct.
234
+ class Compiler
235
+ def initialize(inline: true)
236
+ @ops = []
237
+ @inline = inline
238
+ @subs = {} # [obj_id, flag] => pc or :pending
239
+ @pending = {} # [obj_id, flag] => [[call_idx, body], ...]
240
+ end
241
+
242
+ attr_reader :ops
243
+
244
+ def to_program
245
+ return :oversize if @ops.size > MAX_PROGRAM
246
+
247
+ @ops.flatten!(1)
248
+ end
249
+
250
+ def compile_atom(atom, consume_all)
251
+ case atom
252
+ when Parsanol::Atoms::Str
253
+ bytes = atom.str.unpack("C*")
254
+ emit(STR, bytes, bytes.size, atom.str.bytesize)
255
+ self
256
+ when Parsanol::Atoms::Re
257
+ emit(RE, atom.re, byte_table(atom.re), nil)
258
+ self
259
+ when Parsanol::Atoms::Sequence
260
+ parslets = atom.parslets
261
+ n = parslets.size
262
+ return nil if n.zero?
263
+
264
+ simple = simple_children(parslets, consume_all)
265
+ if simple
266
+ emit(SEQ_SIMPLE, simple, nil, nil)
267
+ return self
268
+ end
269
+
270
+ emit(SEQ_BEGIN)
271
+ last = n - 1
272
+ idx = 0
273
+ while idx < n
274
+ return nil unless compile_atom(parslets[idx], consume_all && idx == last)
275
+
276
+ idx += 1
277
+ end
278
+ emit(SEQ_END)
279
+ self
280
+ when Parsanol::Atoms::Alternative
281
+ alts = atom.alternatives
282
+ count = alts.size
283
+ return nil if count.zero?
284
+
285
+ jumps = []
286
+ idx = 0
287
+ while idx < count
288
+ last_alt = idx == count - 1
289
+ choice = (emit(CHOICE, nil, nil, nil) unless last_alt)
290
+ return nil unless compile_atom(alts[idx], consume_all)
291
+
292
+ unless last_alt
293
+ # The branch matched: drop this CHOICE's backtrack entry
294
+ # (ordered choice commits) before skipping the rest.
295
+ emit(POPBT)
296
+ jumps << emit(JMP, nil, nil, nil)
297
+ end
298
+ patch(choice, 1, flat(@ops.size)) if choice
299
+ idx += 1
300
+ end
301
+ jumps.each { |j| patch(j, 1, flat(@ops.size)) }
302
+ self
303
+ when Parsanol::Atoms::Repetition
304
+ compile_repetition(atom, consume_all)
305
+ when Parsanol::Atoms::Named
306
+ return nil unless compile_atom(atom.parslet, consume_all)
307
+
308
+ emit(NAME_END, atom.name, nil, nil)
309
+ self
310
+ when Parsanol::Atoms::Entity
311
+ compile_entity(atom, consume_all)
312
+ when Parsanol::Atoms::Lookahead
313
+ compile_lookahead(atom, consume_all)
314
+ when Parsanol::Atoms::Ignored
315
+ inner = atom.wrapped_atom
316
+ return nil if inner.nil?
317
+ return nil unless compile_atom(inner, consume_all)
318
+
319
+ emit(DROP)
320
+ self
321
+ when Parsanol::Atoms::Scope
322
+ # Scope only affects capture state; the result tree is the
323
+ # inner atom's tree unchanged. The block is pure DSL evaluated
324
+ # once at compile time. Any Capture/Dynamic inside still fails
325
+ # compilation on its own, so passthrough cannot diverge.
326
+ inner = begin
327
+ atom.block.call
328
+ rescue StandardError
329
+ nil
330
+ end
331
+ return nil if inner.nil?
332
+ return nil unless compile_atom(inner, consume_all)
333
+
334
+ self
335
+ # Dynamic, Capture, Cut, Custom, Infix, unknown
336
+ end
337
+ end
338
+
339
+ def append_subroutines # rubocop:disable Naming/PredicateMethod -- mutating; returns compile success, not a predicate
340
+ until @pending.empty?
341
+ key, list = @pending.shift
342
+ body = list.first[1]
343
+ flag = key[1]
344
+ sub = @ops.size
345
+ # Register before compiling so recursive references resolve.
346
+ @subs[key] = sub
347
+ return false unless compile_atom(body, flag)
348
+
349
+ emit(RET)
350
+ list.each { |entry| patch(entry[0], 1, flat(sub)) } # rubocop:disable Style/HashEachMethods -- Array of [call_idx, body] pairs, not a Hash
351
+ end
352
+ true
353
+ end
354
+
355
+ MAX_PROGRAM = 40_000 # instructions; larger grammars fall back
356
+
357
+ private
358
+
359
+ def emit(op, a = nil, b = nil, c = nil) # rubocop:disable Naming/MethodParameterName -- opcode operand slots
360
+ @ops << [op, a, b, c]
361
+ @ops.size - 1
362
+ end
363
+
364
+ def patch(idx, slot, value)
365
+ @ops[idx][slot] = value
366
+ end
367
+
368
+ # Compiler works in instruction indexes; the executor's pc indexes
369
+ # the flattened array (4 slots per instruction).
370
+ def flat(instr_index)
371
+ instr_index * 4
372
+ end
373
+
374
+ # Returns a flat [op, a, b, c, ...] descriptor array when every
375
+ # child compiles to a single-value fused op, else nil.
376
+ def simple_children(parslets, consume_all)
377
+ kids = []
378
+ last = parslets.size - 1
379
+ parslets.each_with_index do |child, idx|
380
+ spine = consume_all && idx == last
381
+ op = simple_child(child, spine)
382
+ return nil if op.nil?
383
+
384
+ kids.concat(op)
385
+ end
386
+ kids
387
+ end
388
+
389
+ def simple_child(atom, spine)
390
+ case atom
391
+ when Parsanol::Atoms::Str
392
+ bytes = atom.str.unpack("C*")
393
+ [STR, bytes, bytes.size, atom.str.bytesize]
394
+ when Parsanol::Atoms::Re
395
+ [RE, atom.re, byte_table(atom.re), nil]
396
+ when Parsanol::Atoms::Repetition
397
+ min = atom.min
398
+ max = atom.max
399
+ return nil if max&.zero? || spine
400
+
401
+ body = atom.parslet
402
+ if min.zero? && max == 1
403
+ case body
404
+ when Parsanol::Atoms::Re
405
+ [MAYBE_RE, body.re, byte_table(body.re), nil]
406
+ when Parsanol::Atoms::Str
407
+ bytes = body.str.unpack("C*")
408
+ [MAYBE_STR, bytes, bytes.size, body.str.bytesize]
409
+ end
410
+ elsif min >= 1 && max.nil?
411
+ case body
412
+ when Parsanol::Atoms::Re
413
+ [RUN_RE, body.re, byte_table(body.re), min]
414
+ when Parsanol::Atoms::Str
415
+ bytes = body.str.unpack("C*")
416
+ [RUN_STR, bytes, bytes.size, min]
417
+ end
418
+ end
419
+ end
420
+ end
421
+
422
+ # Repetition compilation with terminal fusions:
423
+ # * maybe(0,1) of a single terminal -> one MAYBE_* op (no backtrack
424
+ # entry, no unwind cycle on the empty path)
425
+ # * unbounded-plus of a single terminal (non-spine sites) -> greedy
426
+ # RUN_* op
427
+ def compile_repetition(atom, consume_all)
428
+ min = atom.min
429
+ max = atom.max
430
+ return nil if max&.zero?
431
+
432
+ body = atom.parslet
433
+ if min.zero? && max == 1 && !consume_all
434
+ case body
435
+ when Parsanol::Atoms::Re
436
+ emit(MAYBE_RE, body.re, byte_table(body.re), nil)
437
+ return self
438
+ when Parsanol::Atoms::Str
439
+ bytes = body.str.unpack("C*")
440
+ emit(MAYBE_STR, bytes, bytes.size, body.str.bytesize)
441
+ return self
442
+ end
443
+ end
444
+ if min >= 1 && max.nil? && !consume_all
445
+ case body
446
+ when Parsanol::Atoms::Re
447
+ emit(RUN_RE, body.re, byte_table(body.re), min)
448
+ return self
449
+ when Parsanol::Atoms::Str
450
+ bytes = body.str.unpack("C*")
451
+ emit(RUN_STR, bytes, bytes.size, min)
452
+ return self
453
+ end
454
+ end
455
+
456
+ emit(REP_INIT, min, nil, nil)
457
+ test = emit(REP_TEST, nil, max, nil)
458
+ return nil unless compile_atom(body, false)
459
+
460
+ step = emit(REP_STEP, nil, nil, max)
461
+ exit_ = emit(REP_EXIT, atom.result_tag, consume_all ? 1 : 0, nil)
462
+ patch(test, 1, flat(exit_))
463
+ patch(step, 1, flat(test))
464
+ patch(step, 2, flat(exit_))
465
+ self
466
+ end
467
+
468
+ def compile_entity(atom, consume_all)
469
+ body = begin
470
+ atom.parslet
471
+ rescue StandardError
472
+ nil
473
+ end
474
+ return nil if body.nil?
475
+
476
+ # Leaf entities are transparent: a rule whose body is a single
477
+ # terminal compiles inline, skipping the CALL/RET round trip.
478
+ case body
479
+ when Parsanol::Atoms::Str
480
+ bytes = body.str.unpack("C*")
481
+ emit(STR, bytes, bytes.size, body.str.bytesize)
482
+ return self
483
+ when Parsanol::Atoms::Re
484
+ emit(RE, body.re, byte_table(body.re), nil)
485
+ return self
486
+ end
487
+
488
+ # Non-recursive rules inline: the body's ops replace the CALL/RET
489
+ # round trip entirely. A body currently being compiled (directly
490
+ # or indirectly) is recursive and must stay a subroutine.
491
+ # rubocop:disable Lint/HashCompareByIdentity -- object_id keys; would otherwise pin every atom alive
492
+ if @inline && !(@compiling ||= {})[body.object_id]
493
+ @compiling[body.object_id] = true
494
+ # rubocop:enable Lint/HashCompareByIdentity
495
+ result = compile_atom(body, consume_all)
496
+ @compiling.delete(body.object_id)
497
+ return result if result
498
+
499
+ return nil
500
+ end
501
+
502
+ key = [body.object_id, consume_all]
503
+ sub = @subs[key]
504
+ if sub.is_a?(Integer)
505
+ emit(CALL, flat(sub), nil, nil)
506
+ else
507
+ call_idx = emit(CALL, nil, nil, nil)
508
+ (@pending[key] ||= []) << [call_idx, body]
509
+ @subs[key] = :pending if sub.nil?
510
+ end
511
+ self
512
+ end
513
+
514
+ def compile_lookahead(atom, consume_all)
515
+ positive = atom.positive
516
+ bound = atom.bound_parslet
517
+ return nil if bound.nil?
518
+
519
+ entry = emit(positive ? LOOK_POS : LOOK_NEG, nil, nil, nil)
520
+ return nil unless compile_atom(bound, consume_all)
521
+
522
+ emit(positive ? LOOK_POS_END : LOOK_NEG_END)
523
+ patch(entry, 1, flat(@ops.size)) # continuation for K_NEG entries
524
+ self
525
+ end
526
+
527
+ # 256-entry table: "regex matches a prefix consisting of exactly this
528
+ # ASCII byte" — only when the regex is provably a single-char class.
529
+ def byte_table(re) # rubocop:disable Naming/MethodParameterName
530
+ return nil unless SINGLE_CLASS_RE.match?(re.source)
531
+ return nil if re.match?("") # can match empty -> not byte-local
532
+
533
+ table = Array.new(256, false)
534
+ (1..127).each do |b|
535
+ table[b] = true if re.match?(b.chr)
536
+ end
537
+ table
538
+ end
539
+ end
540
+
541
+ # One execution of a compiled program over one input string.
542
+ # When a parse succeeds but consumed a large fraction of the step
543
+ # budget, the grammar backtracks heavily and the tree interpreter
544
+ # (with its adaptive memoization) is the better engine; #run reports
545
+ # this via :heavy so Base#parse can skip the VM for the grammar.
546
+ class Executor
547
+ def initialize(program, input, consume_all, memoize: false)
548
+ @ops = program
549
+ @input = input
550
+ @consume_all = consume_all
551
+ @memoize = memoize
552
+ end
553
+
554
+ def execute # rubocop:disable Metrics/MethodLength, Metrics/BlockLength, Metrics/BlockNesting -- single dispatch loop; hot path
555
+ ops = @ops
556
+ input = @input
557
+ bytes = input.unpack("C*")
558
+ # Regexp#match?(str, pos) searches FORWARD from pos (unanchored);
559
+ # StringScanner#match? is position-anchored, matching the
560
+ # interpreter's Source#matches? semantics exactly.
561
+ scanner = StringScanner.new(input)
562
+ n = bytes.size
563
+ # The memoized pass is polynomial (bounded by distinct rule ×
564
+ # position pairs); give it room proportional to grammar + input so
565
+ # packrat coverage of failing inputs isn't cut short.
566
+ budget = if @memoize
567
+ (MEMO_BUDGET_CELLS_FACTOR * ops.size) +
568
+ (MEMO_BUDGET_FACTOR * n) + STEP_BUDGET_FIXED
569
+ else
570
+ (STEP_BUDGET_FACTOR * n) + STEP_BUDGET_FIXED
571
+ end
572
+ steps = 0
573
+
574
+ pc = 0
575
+ pos = 0
576
+ rstack = []
577
+ bt = []
578
+ frames = []
579
+ calls = []
580
+
581
+ memo = @memoize ? {} : nil
582
+ memo_stack = []
583
+ trace = ENV["VM_TRACE"] ? [] : nil
584
+
585
+ # rubocop:disable-next Metrics/BlockLength -- the dispatch loop IS execute
586
+ loop do
587
+ steps += 1
588
+ if steps > budget
589
+ if trace
590
+ warn "BUDGET at steps=#{steps} pc=#{pc} pos=#{pos} rstack=#{rstack.size} bt=#{bt.size} calls=#{calls.size}"
591
+ warn "trace tail: #{trace.last(30).inspect}"
592
+ end
593
+ return BUDGET
594
+ end
595
+ trace << [pc, pos] if trace
596
+
597
+ if pc == FAIL
598
+ pc, pos = unwind(bt, rstack, frames, calls, memo, memo_stack)
599
+ return [false, nil] if pc == :fail
600
+ return BAIL if pc == :bail
601
+
602
+ next
603
+ end
604
+
605
+ case ops[pc]
606
+ when STR
607
+ lit = ops[pc + 1]
608
+ ln = ops[pc + 2]
609
+ i = 0
610
+ matched = true
611
+ while i < ln
612
+ if bytes[pos + i] != lit[i]
613
+ matched = false
614
+ break
615
+ end
616
+ i += 1
617
+ end
618
+ if matched
619
+ rstack << ((pos << 20) | ln)
620
+ pos += ops[pc + 3]
621
+ pc += 4
622
+ else
623
+ pc = FAIL
624
+ end
625
+ when RE
626
+ b = bytes[pos]
627
+ matched =
628
+ if (table = ops[pc + 2]) && b && b < 128
629
+ table[b]
630
+ elsif pos < n
631
+ (scanner.pos = pos) && scanner.match?(ops[pc + 1])
632
+ else
633
+ false
634
+ end
635
+ if matched
636
+ w = CHAR_WIDTH[b]
637
+ w = char_width_slow(input, pos) if w.zero?
638
+ rstack << ((pos << 20) | w)
639
+ pos += w
640
+ pc += 4
641
+ else
642
+ pc = FAIL
643
+ end
644
+ when ANY
645
+ if pos < n
646
+ w = CHAR_WIDTH[bytes[pos]]
647
+ w = char_width_slow(input, pos) if w.zero?
648
+ rstack << ((pos << 20) | w)
649
+ pos += w
650
+ pc += 4
651
+ else
652
+ pc = FAIL
653
+ end
654
+ when SEQ_BEGIN
655
+ rstack << SEQ_MARK
656
+ pc += 4
657
+ when SEQ_END
658
+ # Pop pushes in reverse; append and single reverse (O(n), no
659
+ # per-element unshift).
660
+ values = []
661
+ guard = rstack.size
662
+ ok_seq = false
663
+ while guard.positive?
664
+ v = rstack.pop
665
+ guard -= 1
666
+ if v == SEQ_MARK
667
+ ok_seq = true
668
+ break
669
+ end
670
+ values << v
671
+ end
672
+ return [false, nil] unless ok_seq
673
+
674
+ values << :sequence
675
+ values.reverse!
676
+ rstack << values
677
+ pc += 4
678
+ when CHOICE
679
+ bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_ALT
680
+ pc += 4
681
+ when POPBT
682
+ bt.pop(BT_STRIDE)
683
+ pc += 4
684
+ when JMP
685
+ pc = ops[pc + 1]
686
+ when NAME_END
687
+ rstack << VM::NamedValue.new(ops[pc + 1], rstack.pop)
688
+ pc += 4
689
+ when CALL
690
+ sub = ops[pc + 1]
691
+ if memo
692
+ table = (memo[sub] ||= {})
693
+ hit = table[pos]
694
+ if hit
695
+ if hit.equal?(PENDING) || hit.equal?(:fail)
696
+ # :fail — the body already failed at this position;
697
+ # replay the failure without re-running it.
698
+ # PENDING — re-entry at the same rule+position (left
699
+ # recursion); the interpreter loops forever here, so
700
+ # failing this path keeps the VM bounded.
701
+ pc = FAIL
702
+ next
703
+ end
704
+ # Memo hit: replay the subroutine's stack effect without
705
+ # executing it. Values are immutable (packed spans,
706
+ # NamedValue, frozen-shape Arrays), so sharing is safe.
707
+ rstack.concat(hit[1])
708
+ pos = hit[0]
709
+ pc += 4
710
+ next
711
+ end
712
+
713
+ table[pos] = PENDING
714
+ # Lockstep with calls: unwind rolls both back by clen.
715
+ memo_stack << sub << pos << rstack.size
716
+ end
717
+ calls << (pc + 4)
718
+ pc = sub
719
+ when RET
720
+ pc = calls.pop
721
+ return BAIL if pc.nil?
722
+
723
+ if memo
724
+ mlen = memo_stack.pop
725
+ mpos = memo_stack.pop
726
+ msub = memo_stack.pop
727
+ memo[msub][mpos] = [pos, rstack[mlen..]]
728
+ end
729
+ when LOOK_POS
730
+ bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_LOOK_FAIL
731
+ pc += 4
732
+ when LOOK_POS_END
733
+ # Body matched: restore entry's saved pos, drop entry and the
734
+ # body's value, push nil like Lookahead#try.
735
+ pos = bt[-BT_STRIDE + 1]
736
+ bt.pop(BT_STRIDE)
737
+ rstack.pop
738
+ rstack << nil
739
+ pc += 4
740
+ when LOOK_NEG
741
+ bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_NEG
742
+ pc += 4
743
+ when LOOK_NEG_END
744
+ # Body matched: negative lookahead fails. Drop entry + body
745
+ # values, then propagate failure outward.
746
+ rlen = bt[-BT_STRIDE + 2]
747
+ rstack.slice!(rlen..) if rstack.size > rlen
748
+ bt.pop(BT_STRIDE)
749
+ pc = FAIL
750
+ when DROP
751
+ rstack.pop
752
+ rstack << nil
753
+ pc += 4
754
+ when REP_INIT
755
+ frames << 0 << pos << rstack.size << ops[pc + 1] # count,end,rbase,min
756
+ pc += 4
757
+ when REP_TEST
758
+ cbase = frames.size - FRAME_STRIDE
759
+ count = frames[cbase]
760
+ return BAIL if count.nil?
761
+
762
+ max = ops[pc + 2]
763
+ if max && count >= max
764
+ pc = ops[pc + 1] # exit
765
+ else
766
+ bt << ops[pc + 1] << frames[cbase + 1] << rstack.size << cbase << calls.size << K_REP
767
+ pc += 4 # body
768
+ end
769
+ when REP_STEP
770
+ bt.pop(BT_STRIDE)
771
+ cbase = frames.size - FRAME_STRIDE
772
+ count = frames[cbase]
773
+ return BAIL if count.nil?
774
+
775
+ count += 1
776
+ frames[cbase] = count
777
+ frames[cbase + 1] = pos
778
+ max = ops[pc + 3]
779
+ pc = if max && count >= max
780
+ ops[pc + 2] # exit
781
+ else
782
+ ops[pc + 1] # test
783
+ end
784
+ when REP_EXIT
785
+ cbase = frames.size - FRAME_STRIDE
786
+ return BAIL if frames[cbase].nil?
787
+
788
+ if ops[pc + 2] == 1 && pos != n
789
+ frames.slice!(cbase..)
790
+ pc = FAIL
791
+ else
792
+ rbase = frames[cbase + 2]
793
+ values = rstack.pop(rstack.size - rbase)
794
+ values.unshift(ops[pc + 1])
795
+ rstack << values
796
+ frames.slice!(cbase..)
797
+ pc += 4
798
+ end
799
+ when SEQ_SIMPLE
800
+ kids = ops[pc + 1]
801
+ kn = kids.size
802
+ values = [:sequence]
803
+ ki = 0
804
+ failed = false
805
+ while ki < kn
806
+ kop = kids[ki]
807
+ ka = kids[ki + 1]
808
+ kb = kids[ki + 2]
809
+ kc = kids[ki + 3]
810
+ case kop
811
+ when RE
812
+ b = bytes[pos]
813
+ m = if kb && b && b < 128
814
+ kb[b]
815
+ elsif pos < n
816
+ (scanner.pos = pos) && scanner.match?(ka)
817
+ else
818
+ false
819
+ end
820
+ if m
821
+ w = CHAR_WIDTH[b]
822
+ w = char_width_slow(input, pos) if w.zero?
823
+ values << ((pos << 20) | w)
824
+ pos += w
825
+ else
826
+ failed = true
827
+ end
828
+ when STR
829
+ i = 0
830
+ ln = kb
831
+ m = true
832
+ while i < ln
833
+ if bytes[pos + i] != ka[i] # rubocop:disable Metrics/BlockNesting -- fused byte-compare loop
834
+ m = false
835
+ break
836
+ end
837
+ i += 1
838
+ end
839
+ if m
840
+ values << ((pos << 20) | ln)
841
+ pos += kc
842
+ else
843
+ failed = true
844
+ end
845
+ when MAYBE_RE
846
+ b = bytes[pos]
847
+ m = if kb && b && b < 128
848
+ kb[b]
849
+ elsif pos < n
850
+ (scanner.pos = pos) && scanner.match?(ka)
851
+ else
852
+ false
853
+ end
854
+ if m
855
+ w = CHAR_WIDTH[b]
856
+ w = char_width_slow(input, pos) if w.zero?
857
+ values << [:maybe, (pos << 20) | w]
858
+ pos += w
859
+ else
860
+ values << [:maybe]
861
+ end
862
+ when MAYBE_STR
863
+ i = 0
864
+ ln = kb
865
+ m = true
866
+ while i < ln
867
+ if bytes[pos + i] != ka[i] # rubocop:disable Metrics/BlockNesting -- fused byte-compare loop
868
+ m = false
869
+ break
870
+ end
871
+ i += 1
872
+ end
873
+ if m
874
+ values << [:maybe, (pos << 20) | ln]
875
+ pos += kc
876
+ else
877
+ values << [:maybe]
878
+ end
879
+ when RUN_RE
880
+ elem_start = pos
881
+ count = 0
882
+ loop do
883
+ b = bytes[pos]
884
+ m = if kb && b && b < 128
885
+ kb[b]
886
+ elsif pos < n
887
+ (scanner.pos = pos) && scanner.match?(ka)
888
+ else
889
+ false
890
+ end
891
+ break unless m
892
+
893
+ count += 1
894
+ w = CHAR_WIDTH[b]
895
+ w = char_width_slow(input, pos) if w.zero?
896
+ values << ((pos << 20) | w)
897
+ pos += w
898
+ end
899
+ if count < kc
900
+ pos = elem_start
901
+ failed = true
902
+ end
903
+ when RUN_STR
904
+ elem_start = pos
905
+ count = 0
906
+ loop do
907
+ i = 0
908
+ ln = kb
909
+ m = true
910
+ while i < ln
911
+ if bytes[pos + i] != ka[i] # rubocop:disable Metrics/BlockNesting -- fused byte-compare loop
912
+ m = false
913
+ break
914
+ end
915
+ i += 1
916
+ end
917
+ break unless m
918
+
919
+ count += 1
920
+ values << ((pos << 20) | ln)
921
+ pos += ln
922
+ end
923
+ if count < kc
924
+ pos = elem_start
925
+ failed = true
926
+ end
927
+ end
928
+ break if failed
929
+
930
+ ki += 4
931
+ end
932
+ if failed
933
+ pc = FAIL
934
+ else
935
+ rstack << values
936
+ pc += 4
937
+ end
938
+ when MAYBE_RE
939
+ b = bytes[pos]
940
+ matched =
941
+ if (table = ops[pc + 2]) && b && b < 128
942
+ table[b]
943
+ elsif pos < n
944
+ (scanner.pos = pos) && scanner.match?(ops[pc + 1])
945
+ else
946
+ false
947
+ end
948
+ if matched
949
+ w = CHAR_WIDTH[b]
950
+ w = char_width_slow(input, pos) if w.zero?
951
+ rstack << [:maybe, (pos << 20) | w]
952
+ pos += w
953
+ else
954
+ rstack << [:maybe]
955
+ end
956
+ pc += 4
957
+ when MAYBE_STR
958
+ lit = ops[pc + 1]
959
+ ln = ops[pc + 2]
960
+ i = 0
961
+ matched = true
962
+ while i < ln
963
+ if bytes[pos + i] != lit[i]
964
+ matched = false
965
+ break
966
+ end
967
+ i += 1
968
+ end
969
+ if matched
970
+ rstack << [:maybe, (pos << 20) | ln]
971
+ pos += ops[pc + 3]
972
+ else
973
+ rstack << [:maybe]
974
+ end
975
+ pc += 4
976
+ when RUN_RE
977
+ re = ops[pc + 1]
978
+ table = ops[pc + 2]
979
+ min = ops[pc + 3]
980
+ values = [:repetition]
981
+ loop do
982
+ b = bytes[pos]
983
+ m = if table && b && b < 128
984
+ table[b]
985
+ elsif pos < n
986
+ (scanner.pos = pos) && scanner.match?(re)
987
+ else
988
+ false
989
+ end
990
+ break unless m
991
+
992
+ w = CHAR_WIDTH[b]
993
+ w = char_width_slow(input, pos) if w.zero?
994
+ values << ((pos << 20) | w)
995
+ pos += w
996
+ end
997
+ if min && values.size - 1 < min
998
+ pc = FAIL
999
+ else
1000
+ rstack << values
1001
+ pc += 4
1002
+ end
1003
+ when RUN_STR
1004
+ lit = ops[pc + 1]
1005
+ ln = ops[pc + 2]
1006
+ min = ops[pc + 3]
1007
+ values = [:repetition]
1008
+ loop do
1009
+ i = 0
1010
+ matched = true
1011
+ while i < ln
1012
+ if bytes[pos + i] != lit[i]
1013
+ matched = false
1014
+ break
1015
+ end
1016
+ i += 1
1017
+ end
1018
+ break unless matched
1019
+
1020
+ values << ((pos << 20) | ln)
1021
+ pos += ln
1022
+ end
1023
+ if min && values.size - 1 < min
1024
+ pc = FAIL
1025
+ else
1026
+ rstack << values
1027
+ pc += 4
1028
+ end
1029
+ when HALT
1030
+ if @consume_all && pos != n
1031
+ pc = FAIL
1032
+ next
1033
+ end
1034
+ value = VM.materialize(rstack[0], input)
1035
+ # More than ~100 steps per input byte means heavy
1036
+ # backtracking; the memoizing interpreter wins there.
1037
+ if @memoize
1038
+ return [true, value]
1039
+ end
1040
+
1041
+ return steps > (n << 9) + 1000 ? [:heavy, value] : [true, value]
1042
+ else
1043
+ return BAIL
1044
+ end
1045
+ end
1046
+ end
1047
+
1048
+ private
1049
+
1050
+ # Unwind one backtrack entry. Returns [pc, pos] or :fail. Repetition
1051
+ # entries with count >= min complete their tagged array and jump to
1052
+ # the exit continuation with the last iteration end position.
1053
+ def unwind(bt, rstack, frames, calls, memo, memo_stack) # rubocop:disable Naming/MethodParameterName -- stack register names
1054
+ return :fail if bt.empty?
1055
+
1056
+ # Everything above this entry belongs to constructs being
1057
+ # abandoned by the jump (inner alternatives, repetitions, or
1058
+ # subroutine state); discard it along with the entry itself.
1059
+ blen = bt.size - BT_STRIDE
1060
+ if ENV["VM_TRACE"]
1061
+ warn " UNWIND bt=#{bt.each_slice(6).map { |e| "[#{e[0]},#{e[1]},#{e[2]},#{e[3]},#{e[4]},#{e[5]}" }.join(' ')}"
1062
+ end
1063
+ kind = bt.pop
1064
+ clen = bt.pop
1065
+ cbase = bt.pop
1066
+ rlen = bt.pop
1067
+ epos = bt.pop
1068
+ epc = bt.pop
1069
+ bt.slice!(blen..) if bt.size > blen
1070
+
1071
+ rstack.slice!(rlen..) if rstack.size > rlen
1072
+ calls.slice!(clen..) if calls.size > clen
1073
+ if memo
1074
+ # memo_stack carries three slots per live call, so the frames of
1075
+ # every call abandoned by this unwind are the tail above 3*clen.
1076
+ # Every abandoned frame is a call whose body failed — cache that
1077
+ # failure too (full packrat memoization), so later explorations
1078
+ # that reach the same rule+position fail immediately instead of
1079
+ # re-running the body. Grammar inputs that don't parse explore
1080
+ # every alternative; without failure entries those explorations
1081
+ # repeat exponentially and blow the step budget.
1082
+ floor = clen * 3
1083
+ while memo_stack.size > floor
1084
+ memo_stack.pop
1085
+ mpos = memo_stack.pop
1086
+ msub = memo_stack.pop
1087
+ table = memo[msub]
1088
+ table[mpos] = :fail if table
1089
+ end
1090
+ end
1091
+
1092
+ case kind
1093
+ when K_ALT
1094
+ frames.slice!(cbase..) if frames.size > cbase
1095
+ [epc, epos]
1096
+ when K_REP
1097
+ count = frames[cbase]
1098
+ min = frames[cbase + 3]
1099
+ if count.nil? || min.nil?
1100
+ return :bail
1101
+ end
1102
+
1103
+ if count >= min
1104
+ # Repetition succeeds with the completed prefix.
1105
+ rbase = frames[cbase + 2]
1106
+ end_pos = frames[cbase + 1]
1107
+ tag = @ops[epc + 1]
1108
+ check_full = @ops[epc + 2]
1109
+ if check_full == 1 && end_pos != @input.bytesize
1110
+ frames.slice!(cbase..)
1111
+ unwind(bt, rstack, frames, calls, memo, memo_stack)
1112
+ else
1113
+ values = rstack.pop(rstack.size - rbase)
1114
+ values.unshift(tag)
1115
+ rstack << values
1116
+ frames.slice!(cbase..)
1117
+ [epc + 4, end_pos]
1118
+ end
1119
+ else
1120
+ frames.slice!(cbase..)
1121
+ unwind(bt, rstack, frames, calls, memo, memo_stack)
1122
+ end
1123
+ when K_LOOK_FAIL
1124
+ frames.slice!(cbase..) if frames.size > cbase
1125
+ unwind(bt, rstack, frames, calls, memo, memo_stack)
1126
+ when K_NEG
1127
+ # Negative lookahead body failed -> lookahead succeeds.
1128
+ frames.slice!(cbase..) if frames.size > cbase
1129
+ rstack << nil
1130
+ [epc, epos]
1131
+ end
1132
+ end
1133
+
1134
+ def char_width_slow(input, pos)
1135
+ w = CHAR_WIDTH[input.getbyte(pos)]
1136
+ w.zero? ? 1 : w
1137
+ end
1138
+ end
1139
+ end
1140
+ end