parsanol 1.3.15 → 1.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 222de318e9cac8557e165fef1471947f25932448540a63756735581fa79f455c
4
- data.tar.gz: e6659f5259e0e76cf701a0630ed50a48cb4a217d3d6a6ebc5ab703ab2088f062
3
+ metadata.gz: 99c30bd8fcd284b2fe099ffe93f0e462d182906788f87b4bb91b6828111e0b66
4
+ data.tar.gz: e83f0633dd370b5b18c0b8a18c37dc22ce6c4a7ce5d12aa482a8656f545dc1c9
5
5
  SHA512:
6
- metadata.gz: a20812f786fde6730b244e64f322ebd8d5a2a3d59ea9a03743cf8a0f12142f9d4efe2b01c3f3e29a19329871d1290364eac1983048869c14784576f1383b983e
7
- data.tar.gz: 0bd90a5bab7e78b97c5e63abdb3df077cc8372f462a296d798abd28f6718857b3b96403e568267460a07c873754b00b372a14dc802dd415a2abdaae41d34f1e6
6
+ metadata.gz: 7b6aa212fcf424baf769942c3626f71fd790ffdffe44df79fe79e33aa54bdc8e64c0d8a3abf989e9b207e3a0741d7061afae3dd3600750a0422b34da03d44e80
7
+ data.tar.gz: b3386af97c1247146ed6d4f1218466fc02834c0e69bd538edde652fa2e3dcb22226f17760655439394f2482c400478b14b424101eb7927ae3b85e3a829de9f57
@@ -35,20 +35,15 @@ module Parsanol
35
35
  # Compiled-VM fast path: String input, full-consumption semantics.
36
36
  # On failure (or unsupported grammar / budget exhaustion) fall
37
37
  # through to the interpreter, which also produces the exact
38
- # cause-tree diagnostics.
38
+ # cause-tree diagnostics. run_for memoizes heavy-backtracking
39
+ # grammars transparently.
39
40
  if must_consume_all && source.is_a?(String) &&
40
41
  (program = VM.program_for(self))
41
- result = VM.run(program, source, true)
42
+ result = VM.run_for(self, program, source, true)
42
43
  if result == VM::BAIL
43
44
  # Internal bail: fall back to the interpreter and skip the VM
44
45
  # for this grammar from now on.
45
46
  VM.disable_for!(self)
46
- elsif result.first == :heavy
47
- # Succeeded but burned >1/8 of the step budget —
48
- # heavy-backtracking grammar; the memoizing interpreter is
49
- # the better engine from now on.
50
- VM.disable_for!(self)
51
- return finalize_result(result[1])
52
47
  elsif result.first
53
48
  return finalize_result(result[1])
54
49
  end
@@ -153,6 +148,13 @@ module Parsanol
153
148
  to_s(TOP)
154
149
  end
155
150
 
151
+ # Finalizes result by flattening. Public: the native error
152
+ # fallback (both extension and ffi tiers) calls it cross-object to
153
+ # finalize a recovered tree.
154
+ def finalize_result(value)
155
+ flatten(value)
156
+ end
157
+
156
158
  protected
157
159
 
158
160
  # Pre-allocated constant result tuples
@@ -223,11 +225,6 @@ module Parsanol
223
225
 
224
226
  cause.raise
225
227
  end
226
-
227
- # Finalizes result by flattening.
228
- def finalize_result(value)
229
- flatten(value)
230
- end
231
228
  end
232
229
  end
233
230
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parsanol
4
+ module Native
5
+ # Rust engine for runtimes that cannot load C-API extensions
6
+ # (JRuby, TruffleRuby, or an MRI whose platform gem is absent and
7
+ # cannot build one). Binds the parsanol cdylib's C ABI through the
8
+ # `ffi` gem: grammars register once into Rust-side handles keyed by
9
+ # serialized structure; results cross the boundary as flat-u64 batch
10
+ # arrays decoded by the same BatchDecoder used elsewhere.
11
+ module Ffi
12
+ class Error < StandardError
13
+ end
14
+
15
+ class << self
16
+ @available = nil
17
+
18
+ def available?
19
+ return @available unless @available.nil?
20
+
21
+ @available = begin
22
+ require "ffi"
23
+ lib = locate_library
24
+ lib && bind_library(lib)
25
+ rescue LoadError, StandardError
26
+ false
27
+ end
28
+ end
29
+
30
+ # Registers (once, cached) and parses. +grammar+ is a grammar
31
+ # atom or a pre-serialized JSON string. Mirrors Native.parse's
32
+ # contract, including the single reporter-pass fallback to the
33
+ # pure-Ruby engine for parslet-compatible failure errors.
34
+ def parse(grammar, input)
35
+ unless available? && @binding
36
+ raise Error, "parsanol FFI library not available"
37
+ end
38
+
39
+ if grammar.is_a?(String)
40
+ json = grammar
41
+ atom = nil
42
+ else
43
+ json = ::Parsanol::Native::Parser.serialize_grammar(grammar)
44
+ atom = grammar
45
+ end
46
+ handle = ((@handles ||= {})[json] ||= @binding.c_register(json))
47
+ raise Error, "grammar registration failed" if handle.zero?
48
+
49
+ written = parse_into(handle, input)
50
+ if written.positive?
51
+ return ::Parsanol::Native::BatchDecoder.decode_and_flatten(
52
+ @buffer.read_array_of_uint64(written), input, ::Parsanol::Slice
53
+ )
54
+ end
55
+
56
+ message = @binding.c_last_error.to_s
57
+ if atom
58
+ return ::Parsanol::Native.raise_native_parse_error(
59
+ RuntimeError.new(message), atom, input
60
+ )
61
+ end
62
+
63
+ raise Error, "parse failed: #{message}"
64
+ end
65
+
66
+ private
67
+
68
+ # Search order: explicit override, gem-vendored cdylib next to
69
+ # this file, then the system linker path.
70
+ def locate_library
71
+ candidates = []
72
+ env = ENV.fetch("PARSANOL_FFI_LIB", nil)
73
+ candidates << env if env && !env.empty?
74
+ here = File.dirname(__FILE__)
75
+ %w[libparsanol.dylib libparsanol.so parsanol.dll].each do |name|
76
+ path = File.expand_path(name, here)
77
+ candidates << path if File.file?(path)
78
+ end
79
+ candidates << "parsanol"
80
+ candidates.each do |cand|
81
+ return cand if File.file?(cand)
82
+
83
+ begin
84
+ FFI::DynamicLibrary.open(cand, FFI::DynamicLibrary::RTLD_NOW)
85
+ return cand
86
+ rescue StandardError
87
+ next
88
+ end
89
+ end
90
+ nil
91
+ end
92
+
93
+ def bind_library(path) # rubocop:disable Naming/PredicateMethod -- binds and reports success
94
+ binding_mod = Module.new do
95
+ extend FFI::Library
96
+
97
+ ffi_lib path
98
+ attach_function :c_register, :parsanol_c_register,
99
+ %i[string], :uint64
100
+ attach_function :c_parse, :parsanol_c_parse,
101
+ %i[uint64 string pointer size_t], :long_long
102
+ attach_function :c_last_error, :parsanol_c_last_error,
103
+ [], :string
104
+ attach_function :c_release, :parsanol_c_release,
105
+ %i[uint64], :void
106
+ end
107
+ # Probe the symbols now so a mismatched library fails loudly
108
+ # at availability time, not at first parse.
109
+ probe = binding_mod.c_last_error
110
+ @binding = binding_mod
111
+ @buffer = nil
112
+ !probe.nil? || true
113
+ end
114
+
115
+ # Two-call buffer protocol: cap=0 asks for the needed size, then
116
+ # one allocation carries the whole batch.
117
+ def parse_into(handle, input)
118
+ needed = @binding.c_parse(handle, input, nil, 0)
119
+ return needed unless needed.negative?
120
+
121
+ @buffer = FFI::MemoryPointer.new(:uint64, -needed)
122
+ @binding.c_parse(handle, input, @buffer, -needed)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -18,7 +18,7 @@ module Parsanol
18
18
  def available?
19
19
  return @cached_available unless @cached_available.nil?
20
20
 
21
- @cached_available = begin
21
+ @ext_loaded = begin
22
22
  # Try versioned path first (released gem), then non-versioned (local dev)
23
23
  ruby_version = RUBY_VERSION.split(".").take(2).join(".")
24
24
  begin
@@ -30,6 +30,13 @@ module Parsanol
30
30
  rescue LoadError
31
31
  false
32
32
  end
33
+ @cached_available = @ext_loaded || Ffi.available?
34
+ end
35
+
36
+ # True when the MRI C-API extension is loaded; false when the
37
+ # engine runs through the ffi-gem cdylib tier instead.
38
+ def extension_loaded?
39
+ @ext_loaded ? true : false
33
40
  end
34
41
 
35
42
  # Parse input with a Ruby grammar, returning clean AST.
@@ -10,6 +10,8 @@ require "parsanol/native/batch_decoder"
10
10
 
11
11
  module Parsanol
12
12
  module Native
13
+ # ffi-gem cdylib tier: lazy — only loaded when the MRI extension is absent.
14
+ autoload :Ffi, "parsanol/native/ffi"
13
15
  class << self
14
16
  # Check if native extension is available
15
17
  def available?
@@ -38,6 +40,10 @@ module Parsanol
38
40
  def parse(grammar, input)
39
41
  raise LoadError, "Native parser not available" unless available?
40
42
 
43
+ # ffi-gem tier (JRuby/TruffleRuby/no-binary MRI): same contract,
44
+ # batch-decoded results, single reporter-pass error fallback.
45
+ return Ffi.parse(grammar, input) unless Parser.extension_loaded?
46
+
41
47
  # Both sub-methods return the final decoded tree; on native failure
42
48
  # they fall back to the pure-Ruby parser, whose result is already
43
49
  # final and must not be transformed again.
@@ -194,7 +200,20 @@ module Parsanol
194
200
  # pre-serialized JSON grammars the native message is wrapped in a
195
201
  # Parsanol::ParseFailed directly.
196
202
  def raise_native_parse_error(error, grammar, input)
197
- return grammar.parse(input) if grammar.respond_to?(:parse)
203
+ if grammar.respond_to?(:parse)
204
+ # One interpreter pass with the error reporter attached covers
205
+ # both jobs: a success means the native backend could not
206
+ # express the grammar (recover the tree), a failure raises the
207
+ # parslet-compatible cause tree. The native backend has
208
+ # already failed, so a separate plain attempt first would be a
209
+ # wasted parse.
210
+ reporter = Parsanol::ErrorReporter::Tree.new
211
+ source = Parsanol::Source.new(input)
212
+ success, value = grammar.run_with_context(source, reporter, true)
213
+ return grammar.finalize_result(value) if success
214
+
215
+ value.raise
216
+ end
198
217
 
199
218
  source = Parsanol::Source.new(input)
200
219
  cause = Parsanol::Cause.new(error.message, source, source.bytepos)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.15"
4
+ VERSION = "1.3.17"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -71,9 +71,18 @@ module Parsanol
71
71
  # and may skip the VM for this grammar afterwards. A clean
72
72
  # [false, nil] means the input genuinely does not parse.
73
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
74
81
 
75
82
  STEP_BUDGET_FACTOR = 200
76
83
  STEP_BUDGET_FIXED = 10_000
84
+ MEMO_BUDGET_FACTOR = 4_096
85
+ MEMO_BUDGET_CELLS_FACTOR = 16
77
86
 
78
87
  class << self
79
88
  @programs = {}
@@ -108,13 +117,27 @@ module Parsanol
108
117
 
109
118
  def clear_program_cache
110
119
  @programs&.clear
120
+ @heavy&.clear
111
121
  end
112
122
 
113
123
  # Compiles the grammar rooted at +atom+. Returns the flat program
114
124
  # Array, or nil when the grammar uses unsupported atoms.
115
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)
116
139
  root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
117
- compiler = Compiler.new
140
+ compiler = Compiler.new(inline: inline)
118
141
  return nil unless compiler.compile_atom(root, true)
119
142
 
120
143
  # HALT terminates the MAIN program; subroutines follow it so the
@@ -128,8 +151,58 @@ module Parsanol
128
151
  # Executes a compiled program. Returns [true, value] on success;
129
152
  # [false, nil] on failure or budget exhaustion — callers fall back
130
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.
131
161
  def run(program, input, consume_all)
132
- Executor.new(program, input, consume_all).execute
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
133
206
  end
134
207
 
135
208
  # Converts the VM value tree into the interpreter's value tree.
@@ -159,8 +232,9 @@ module Parsanol
159
232
  # as subroutines, keyed by [body object_id, consume_all] so spine
160
233
  # sites (consume_all=true) and inner sites stay semantically distinct.
161
234
  class Compiler
162
- def initialize
235
+ def initialize(inline: true)
163
236
  @ops = []
237
+ @inline = inline
164
238
  @subs = {} # [obj_id, flag] => pc or :pending
165
239
  @pending = {} # [obj_id, flag] => [[call_idx, body], ...]
166
240
  end
@@ -168,7 +242,7 @@ module Parsanol
168
242
  attr_reader :ops
169
243
 
170
244
  def to_program
171
- return nil if @ops.size > MAX_PROGRAM
245
+ return :oversize if @ops.size > MAX_PROGRAM
172
246
 
173
247
  @ops.flatten!(1)
174
248
  end
@@ -244,7 +318,21 @@ module Parsanol
244
318
 
245
319
  emit(DROP)
246
320
  self
247
- # Dynamic, Capture, Scope, Cut, Custom, Infix, unknown
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
248
336
  end
249
337
  end
250
338
 
@@ -401,7 +489,7 @@ module Parsanol
401
489
  # round trip entirely. A body currently being compiled (directly
402
490
  # or indirectly) is recursive and must stay a subroutine.
403
491
  # rubocop:disable Lint/HashCompareByIdentity -- object_id keys; would otherwise pin every atom alive
404
- unless (@compiling ||= {})[body.object_id]
492
+ if @inline && !(@compiling ||= {})[body.object_id]
405
493
  @compiling[body.object_id] = true
406
494
  # rubocop:enable Lint/HashCompareByIdentity
407
495
  result = compile_atom(body, consume_all)
@@ -456,10 +544,11 @@ module Parsanol
456
544
  # (with its adaptive memoization) is the better engine; #run reports
457
545
  # this via :heavy so Base#parse can skip the VM for the grammar.
458
546
  class Executor
459
- def initialize(program, input, consume_all)
547
+ def initialize(program, input, consume_all, memoize: false)
460
548
  @ops = program
461
549
  @input = input
462
550
  @consume_all = consume_all
551
+ @memoize = memoize
463
552
  end
464
553
 
465
554
  def execute # rubocop:disable Metrics/MethodLength, Metrics/BlockLength, Metrics/BlockNesting -- single dispatch loop; hot path
@@ -471,7 +560,15 @@ module Parsanol
471
560
  # interpreter's Source#matches? semantics exactly.
472
561
  scanner = StringScanner.new(input)
473
562
  n = bytes.size
474
- budget = (STEP_BUDGET_FACTOR * n) + STEP_BUDGET_FIXED
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
475
572
  steps = 0
476
573
 
477
574
  pc = 0
@@ -481,13 +578,24 @@ module Parsanol
481
578
  frames = []
482
579
  calls = []
483
580
 
581
+ memo = @memoize ? {} : nil
582
+ memo_stack = []
583
+ trace = ENV["VM_TRACE"] ? [] : nil
584
+
484
585
  # rubocop:disable-next Metrics/BlockLength -- the dispatch loop IS execute
485
586
  loop do
486
587
  steps += 1
487
- return BAIL if steps > budget
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
488
596
 
489
597
  if pc == FAIL
490
- pc, pos = unwind(bt, rstack, frames, calls)
598
+ pc, pos = unwind(bt, rstack, frames, calls, memo, memo_stack)
491
599
  return [false, nil] if pc == :fail
492
600
  return BAIL if pc == :bail
493
601
 
@@ -579,11 +687,45 @@ module Parsanol
579
687
  rstack << VM::NamedValue.new(ops[pc + 1], rstack.pop)
580
688
  pc += 4
581
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
582
717
  calls << (pc + 4)
583
- pc = ops[pc + 1]
718
+ pc = sub
584
719
  when RET
585
720
  pc = calls.pop
586
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
587
729
  when LOOK_POS
588
730
  bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_LOOK_FAIL
589
731
  pc += 4
@@ -735,6 +877,8 @@ module Parsanol
735
877
  values << [:maybe]
736
878
  end
737
879
  when RUN_RE
880
+ elem_start = pos
881
+ count = 0
738
882
  loop do
739
883
  b = bytes[pos]
740
884
  m = if kb && b && b < 128
@@ -746,12 +890,19 @@ module Parsanol
746
890
  end
747
891
  break unless m
748
892
 
893
+ count += 1
749
894
  w = CHAR_WIDTH[b]
750
895
  w = char_width_slow(input, pos) if w.zero?
751
896
  values << ((pos << 20) | w)
752
897
  pos += w
753
898
  end
899
+ if count < kc
900
+ pos = elem_start
901
+ failed = true
902
+ end
754
903
  when RUN_STR
904
+ elem_start = pos
905
+ count = 0
755
906
  loop do
756
907
  i = 0
757
908
  ln = kb
@@ -765,9 +916,14 @@ module Parsanol
765
916
  end
766
917
  break unless m
767
918
 
919
+ count += 1
768
920
  values << ((pos << 20) | ln)
769
921
  pos += ln
770
922
  end
923
+ if count < kc
924
+ pos = elem_start
925
+ failed = true
926
+ end
771
927
  end
772
928
  break if failed
773
929
 
@@ -878,6 +1034,10 @@ module Parsanol
878
1034
  value = VM.materialize(rstack[0], input)
879
1035
  # More than ~100 steps per input byte means heavy
880
1036
  # backtracking; the memoizing interpreter wins there.
1037
+ if @memoize
1038
+ return [true, value]
1039
+ end
1040
+
881
1041
  return steps > (n << 9) + 1000 ? [:heavy, value] : [true, value]
882
1042
  else
883
1043
  return BAIL
@@ -890,7 +1050,7 @@ module Parsanol
890
1050
  # Unwind one backtrack entry. Returns [pc, pos] or :fail. Repetition
891
1051
  # entries with count >= min complete their tagged array and jump to
892
1052
  # the exit continuation with the last iteration end position.
893
- def unwind(bt, rstack, frames, calls) # rubocop:disable Naming/MethodParameterName -- stack register names
1053
+ def unwind(bt, rstack, frames, calls, memo, memo_stack) # rubocop:disable Naming/MethodParameterName -- stack register names
894
1054
  return :fail if bt.empty?
895
1055
 
896
1056
  # Everything above this entry belongs to constructs being
@@ -910,6 +1070,24 @@ module Parsanol
910
1070
 
911
1071
  rstack.slice!(rlen..) if rstack.size > rlen
912
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
913
1091
 
914
1092
  case kind
915
1093
  when K_ALT
@@ -930,7 +1108,7 @@ module Parsanol
930
1108
  check_full = @ops[epc + 2]
931
1109
  if check_full == 1 && end_pos != @input.bytesize
932
1110
  frames.slice!(cbase..)
933
- unwind(bt, rstack, frames, calls)
1111
+ unwind(bt, rstack, frames, calls, memo, memo_stack)
934
1112
  else
935
1113
  values = rstack.pop(rstack.size - rbase)
936
1114
  values.unshift(tag)
@@ -940,11 +1118,11 @@ module Parsanol
940
1118
  end
941
1119
  else
942
1120
  frames.slice!(cbase..)
943
- unwind(bt, rstack, frames, calls)
1121
+ unwind(bt, rstack, frames, calls, memo, memo_stack)
944
1122
  end
945
1123
  when K_LOOK_FAIL
946
1124
  frames.slice!(cbase..) if frames.size > cbase
947
- unwind(bt, rstack, frames, calls)
1125
+ unwind(bt, rstack, frames, calls, memo, memo_stack)
948
1126
  when K_NEG
949
1127
  # Negative lookahead body failed -> lookahead succeeds.
950
1128
  frames.slice!(cbase..) if frames.size > cbase
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.15
4
+ version: 1.3.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -215,6 +215,7 @@ files:
215
215
  - lib/parsanol/native.rb
216
216
  - lib/parsanol/native/batch_decoder.rb
217
217
  - lib/parsanol/native/dynamic.rb
218
+ - lib/parsanol/native/ffi.rb
218
219
  - lib/parsanol/native/parser.rb
219
220
  - lib/parsanol/native/serializer.rb
220
221
  - lib/parsanol/native/transformer.rb