parsanol 1.3.14-arm-linux → 1.3.15-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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee82309e5f5550d8b7e874fb457082c29e441044c21f0149108947c1908fc312
4
- data.tar.gz: a58b979b3cca49c5fcc3ad04bbc592f2dc1995883500e13e1bdcab73fa3e41a9
3
+ metadata.gz: e98e448bc4374451a62e175387759b4db66c90160e5df3204e652c17a751ddd7
4
+ data.tar.gz: 7f081c2cd9e6fac46cd4e43d15c91a6755af3aad8b01c35b5cb542d190cf2786
5
5
  SHA512:
6
- metadata.gz: d2cd46409841355d16f45d29cbe1d87fc57107bef76de6ef85c7f6cf9b154e25a0f6cb5b05f62235319b8ece92f283f6da772f834fbc629a11e867d23fd90435
7
- data.tar.gz: b44083dd31df4bde64bb04d319ef9ef605f5aca0c1f03302c284918e6779bc8c809b774d59ec92ec9acb44728a2b3acf00945dc5b9488c9f2f8627c4ff11b7c9
6
+ metadata.gz: 682268466119952e99a678a79270d6fe69a449630665a8e082e7f01f709903f6190552053e6328ec74f47fd452d1b946bedd5b89687a20594debbe33a6e9a2d2
7
+ data.tar.gz: 386e99e3a11788991e7b1fc5fb0ae55a6c95caa0b60508c62c3f997492c213887135d9d7b4bcb04a72b7d9d8c8bd20a9b20b105b6a2e31cc78b814db32380306
@@ -30,9 +30,34 @@ module Parsanol
30
30
  # @return [Object] the parsed result
31
31
  # @raise [Parsanol::ParseFailed] on parse failure
32
32
  def parse(source, options = {})
33
- input = normalize_input(source)
34
33
  must_consume_all = !options[:prefix]
35
34
 
35
+ # Compiled-VM fast path: String input, full-consumption semantics.
36
+ # On failure (or unsupported grammar / budget exhaustion) fall
37
+ # through to the interpreter, which also produces the exact
38
+ # cause-tree diagnostics.
39
+ if must_consume_all && source.is_a?(String) &&
40
+ (program = VM.program_for(self))
41
+ result = VM.run(program, source, true)
42
+ if result == VM::BAIL
43
+ # Internal bail: fall back to the interpreter and skip the VM
44
+ # for this grammar from now on.
45
+ 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
+ elsif result.first
53
+ return finalize_result(result[1])
54
+ end
55
+ # Clean [false, nil]: the input does not parse — the interpreter
56
+ # reparse below produces the detailed cause tree.
57
+ end
58
+
59
+ input = normalize_input(source)
60
+
36
61
  # Initial parse attempt (no error collection)
37
62
  success, value = run_with_context(input, nil, must_consume_all)
38
63
  return finalize_result(value) if success
@@ -132,9 +132,11 @@ module Parsanol
132
132
  # Transform the value
133
133
  transformed = transform(value, named: true)
134
134
 
135
- # Check if value is a tagged repetition from native parser
135
+ # Check if value is a tagged repetition from native parser.
136
+ # The Rust handle path tags with Symbols, the batch decoder with
137
+ # ":repetition" Strings — accept both.
136
138
  is_tagged_repetition = value.is_a?(Array) && !value.empty? &&
137
- value.first.is_a?(String) && value.first == REPETITION_TAG
139
+ [REPETITION_SYM, REPETITION_TAG].include?(value.first)
138
140
 
139
141
  # Check RAW value for repetition pattern BEFORE transformation
140
142
  # Array with items that all have the parent key
@@ -203,13 +205,12 @@ module Parsanol
203
205
  # Empty array from repetition stays as empty array
204
206
  if transformed.empty?
205
207
  { sym_key => EMPTY_ARRAY }
206
- # Check if items already have the same key (avoid double-wrapping)
207
- elsif transformed.all? do |item|
208
- item.is_a?(Hash) && item.key?(sym_key)
209
- end
208
+ # Hash items already carry their own capture names — a
209
+ # repetition of named captures keeps them as-is (parslet
210
+ # semantics); only unnamed items get the parent name per item.
211
+ elsif transformed.all?(Hash)
210
212
  { sym_key => transformed }
211
213
  else
212
- # Wrap each item with the name
213
214
  { sym_key => transformed.map { |item| { sym_key => item } } }
214
215
  end
215
216
  elsif transformed.is_a?(::Parsanol::Slice) && transformed.empty?
@@ -38,14 +38,14 @@ module Parsanol
38
38
  def parse(grammar, input)
39
39
  raise LoadError, "Native parser not available" unless available?
40
40
 
41
- raw_ast =
42
- if grammar.is_a?(String)
43
- parse_json_grammar(grammar, input)
44
- else
45
- parse_atom_grammar(grammar, input)
46
- end
47
-
48
- BatchDecoder.decode_and_flatten(raw_ast, input, Parsanol::Slice)
41
+ # Both sub-methods return the final decoded tree; on native failure
42
+ # they fall back to the pure-Ruby parser, whose result is already
43
+ # final and must not be transformed again.
44
+ if grammar.is_a?(String)
45
+ parse_json_grammar(grammar, input)
46
+ else
47
+ parse_atom_grammar(grammar, input)
48
+ end
49
49
  end
50
50
 
51
51
  # Memory-bounded parsing without packrat cache.
@@ -65,12 +65,14 @@ module Parsanol
65
65
  Parser.serialize_grammar(grammar)
66
66
  end
67
67
 
68
+ # Decode here, not around the fallback: raise_native_parse_error
69
+ # returns the Ruby parser's already-final tree on success.
68
70
  begin
69
- raw_ast = _parse_fresh_raw(grammar_json, input)
71
+ BatchDecoder.decode_and_flatten(_parse_fresh_raw(grammar_json, input),
72
+ input, Parsanol::Slice)
70
73
  rescue RuntimeError => e
71
74
  raise_native_parse_error(e, grammar, input)
72
75
  end
73
- BatchDecoder.decode_and_flatten(raw_ast, input, Parsanol::Slice)
74
76
  end
75
77
 
76
78
  # Parse and return RAW AST without transformation.
@@ -201,7 +203,8 @@ module Parsanol
201
203
 
202
204
  # Pre-serialized JSON grammar path (library authors with cached JSON).
203
205
  def parse_json_grammar(grammar_json, input)
204
- _parse_raw(grammar_json, input)
206
+ BatchDecoder.decode_and_flatten(_parse_raw(grammar_json, input),
207
+ input, Parsanol::Slice)
205
208
  rescue RuntimeError => e
206
209
  raise_native_parse_error(e, grammar_json, input)
207
210
  end
@@ -212,12 +215,16 @@ module Parsanol
212
215
  handle = Parser.grammar_handle(grammar)
213
216
 
214
217
  begin
215
- _parse_handle(handle, input)
218
+ BatchDecoder.decode_and_flatten(_parse_handle(handle, input),
219
+ input, Parsanol::Slice)
216
220
  rescue ArgumentError
217
221
  # Handle dropped Rust-side (e.g. cache cleared): re-register once.
218
222
  Parser.invalidate_handle(handle)
219
223
  begin
220
- _parse_handle(Parser.grammar_handle(grammar), input)
224
+ BatchDecoder.decode_and_flatten(
225
+ _parse_handle(Parser.grammar_handle(grammar), input),
226
+ input, Parsanol::Slice
227
+ )
221
228
  rescue RuntimeError => e
222
229
  raise_native_parse_error(e, grammar, input)
223
230
  end
@@ -109,32 +109,36 @@ module Parsanol
109
109
  # result[:name].to_s # => "hello"
110
110
  #
111
111
  def parse(input, mode_or_opts = {}, **kwargs)
112
- if mode_or_opts.is_a?(Hash) && !kwargs.key?(:mode)
113
- # Legacy API: parse(input, options={})
114
- merged = mode_or_opts.merge(kwargs)
115
- if Parsanol::Native.available? && !merged.key?(:prefix) &&
116
- !merged.key?(:reporter)
117
- # Native backend by default; falls back to pure Ruby inside
118
- # parse_native when the extension is missing.
119
- parse_native(input, merged)
112
+ opts =
113
+ if mode_or_opts.is_a?(Hash)
114
+ mode_or_opts.merge(kwargs)
115
+ elsif mode_or_opts.nil?
116
+ kwargs
120
117
  else
121
- super(input, merged)
118
+ # New API: parse(input, :mode, **options)
119
+ kwargs[:mode] = mode_or_opts
120
+ kwargs
122
121
  end
122
+ # parse(input, {mode: :ruby, ...}) is the same call shape a subclass
123
+ # forwarding options via super produces; :mode must be honored
124
+ # wherever it appears, or those callers silently get the native path.
125
+ mode = opts.delete(:mode) ||
126
+ (if Parsanol::Native.available? && !opts.key?(:prefix) &&
127
+ !opts.key?(:reporter)
128
+ :native
129
+ else
130
+ :ruby
131
+ end)
132
+ case mode
133
+ when :ruby
134
+ super(input, opts)
135
+ when :native
136
+ parse_native(input, opts)
137
+ when :json
138
+ parse_json(input, opts)
123
139
  else
124
- # New API: parse(input, mode:, **options)
125
- mode = kwargs.delete(:mode) ||
126
- (Parsanol::Native.available? ? :native : :ruby)
127
- case mode
128
- when :ruby
129
- super(input, kwargs)
130
- when :native
131
- parse_native(input, kwargs)
132
- when :json
133
- parse_json(input, kwargs)
134
- else
135
- raise ArgumentError,
136
- "Unknown mode: #{mode}. Valid modes: :ruby, :native, :json"
137
- end
140
+ raise ArgumentError,
141
+ "Unknown mode: #{mode}. Valid modes: :ruby, :native, :json"
138
142
  end
139
143
  end
140
144
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.14"
4
+ VERSION = "1.3.15"
5
5
  end
@@ -0,0 +1,962 @@
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
+
75
+ STEP_BUDGET_FACTOR = 200
76
+ STEP_BUDGET_FIXED = 10_000
77
+
78
+ class << self
79
+ @programs = {}
80
+
81
+ # Cached compile keyed by root-atom object identity. Atoms are
82
+ # effectively immutable once constructed; false caches grammars the
83
+ # VM cannot handle so we do not re-walk them on every parse.
84
+ #
85
+ # A grammar whose VM run failed once (unsupported backtracking shape
86
+ # or step budget) is marked :fallback so later parses skip the VM
87
+ # attempt entirely — the interpreter result is identical.
88
+ def program_for(atom)
89
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
90
+ key = root.object_id
91
+ cache = (@programs ||= {})
92
+ cached = cache[key]
93
+ return nil if cached == :fallback
94
+ return cached if key?(cache, key)
95
+
96
+ cache[key] = compile(atom)
97
+ end
98
+
99
+ # Marks a grammar as VM-incompatible after a runtime failure.
100
+ def disable_for!(atom)
101
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
102
+ (@programs ||= {})[root.object_id] = :fallback # rubocop:disable Lint/HashCompareByIdentity -- object_id keys avoid holding strong references to grammar atoms
103
+ end
104
+
105
+ def key?(cache, key) # rubocop:disable Naming/PredicateMethod -- mirrors Hash#key?
106
+ cache.key?(key)
107
+ end
108
+
109
+ def clear_program_cache
110
+ @programs&.clear
111
+ end
112
+
113
+ # Compiles the grammar rooted at +atom+. Returns the flat program
114
+ # Array, or nil when the grammar uses unsupported atoms.
115
+ def compile(atom)
116
+ root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
117
+ compiler = Compiler.new
118
+ return nil unless compiler.compile_atom(root, true)
119
+
120
+ # HALT terminates the MAIN program; subroutines follow it so the
121
+ # root CALL's return address can never collide with a subroutine.
122
+ compiler.ops << [HALT, nil, nil, nil]
123
+ return nil unless compiler.append_subroutines
124
+
125
+ compiler.to_program
126
+ end
127
+
128
+ # Executes a compiled program. Returns [true, value] on success;
129
+ # [false, nil] on failure or budget exhaustion — callers fall back
130
+ # to the interpreter, which reproduces exact diagnostics.
131
+ def run(program, input, consume_all)
132
+ Executor.new(program, input, consume_all).execute
133
+ end
134
+
135
+ # Converts the VM value tree into the interpreter's value tree.
136
+ def materialize(value, input)
137
+ case value
138
+ when Integer
139
+ pos = value >> 20
140
+ Slice.new(pos, input.byteslice(pos, value & MAX_PACK_LEN), input)
141
+ when NamedValue
142
+ { value.name => Mat.flatten(materialize(value.value, input), true) }
143
+ when Array
144
+ value.map { |v| materialize(v, input) }
145
+ when Hash
146
+ value.transform_values { |v| materialize(v, input) }
147
+ else
148
+ value
149
+ end
150
+ end
151
+ end
152
+
153
+ module Mat
154
+ extend Parsanol::Atoms::CanFlatten
155
+ end
156
+ private_constant :Mat
157
+
158
+ # Compiles atoms into a flat stride-4 program. Entity bodies compile
159
+ # as subroutines, keyed by [body object_id, consume_all] so spine
160
+ # sites (consume_all=true) and inner sites stay semantically distinct.
161
+ class Compiler
162
+ def initialize
163
+ @ops = []
164
+ @subs = {} # [obj_id, flag] => pc or :pending
165
+ @pending = {} # [obj_id, flag] => [[call_idx, body], ...]
166
+ end
167
+
168
+ attr_reader :ops
169
+
170
+ def to_program
171
+ return nil if @ops.size > MAX_PROGRAM
172
+
173
+ @ops.flatten!(1)
174
+ end
175
+
176
+ def compile_atom(atom, consume_all)
177
+ case atom
178
+ when Parsanol::Atoms::Str
179
+ bytes = atom.str.unpack("C*")
180
+ emit(STR, bytes, bytes.size, atom.str.bytesize)
181
+ self
182
+ when Parsanol::Atoms::Re
183
+ emit(RE, atom.re, byte_table(atom.re), nil)
184
+ self
185
+ when Parsanol::Atoms::Sequence
186
+ parslets = atom.parslets
187
+ n = parslets.size
188
+ return nil if n.zero?
189
+
190
+ simple = simple_children(parslets, consume_all)
191
+ if simple
192
+ emit(SEQ_SIMPLE, simple, nil, nil)
193
+ return self
194
+ end
195
+
196
+ emit(SEQ_BEGIN)
197
+ last = n - 1
198
+ idx = 0
199
+ while idx < n
200
+ return nil unless compile_atom(parslets[idx], consume_all && idx == last)
201
+
202
+ idx += 1
203
+ end
204
+ emit(SEQ_END)
205
+ self
206
+ when Parsanol::Atoms::Alternative
207
+ alts = atom.alternatives
208
+ count = alts.size
209
+ return nil if count.zero?
210
+
211
+ jumps = []
212
+ idx = 0
213
+ while idx < count
214
+ last_alt = idx == count - 1
215
+ choice = (emit(CHOICE, nil, nil, nil) unless last_alt)
216
+ return nil unless compile_atom(alts[idx], consume_all)
217
+
218
+ unless last_alt
219
+ # The branch matched: drop this CHOICE's backtrack entry
220
+ # (ordered choice commits) before skipping the rest.
221
+ emit(POPBT)
222
+ jumps << emit(JMP, nil, nil, nil)
223
+ end
224
+ patch(choice, 1, flat(@ops.size)) if choice
225
+ idx += 1
226
+ end
227
+ jumps.each { |j| patch(j, 1, flat(@ops.size)) }
228
+ self
229
+ when Parsanol::Atoms::Repetition
230
+ compile_repetition(atom, consume_all)
231
+ when Parsanol::Atoms::Named
232
+ return nil unless compile_atom(atom.parslet, consume_all)
233
+
234
+ emit(NAME_END, atom.name, nil, nil)
235
+ self
236
+ when Parsanol::Atoms::Entity
237
+ compile_entity(atom, consume_all)
238
+ when Parsanol::Atoms::Lookahead
239
+ compile_lookahead(atom, consume_all)
240
+ when Parsanol::Atoms::Ignored
241
+ inner = atom.wrapped_atom
242
+ return nil if inner.nil?
243
+ return nil unless compile_atom(inner, consume_all)
244
+
245
+ emit(DROP)
246
+ self
247
+ # Dynamic, Capture, Scope, Cut, Custom, Infix, unknown
248
+ end
249
+ end
250
+
251
+ def append_subroutines # rubocop:disable Naming/PredicateMethod -- mutating; returns compile success, not a predicate
252
+ until @pending.empty?
253
+ key, list = @pending.shift
254
+ body = list.first[1]
255
+ flag = key[1]
256
+ sub = @ops.size
257
+ # Register before compiling so recursive references resolve.
258
+ @subs[key] = sub
259
+ return false unless compile_atom(body, flag)
260
+
261
+ emit(RET)
262
+ list.each { |entry| patch(entry[0], 1, flat(sub)) } # rubocop:disable Style/HashEachMethods -- Array of [call_idx, body] pairs, not a Hash
263
+ end
264
+ true
265
+ end
266
+
267
+ MAX_PROGRAM = 40_000 # instructions; larger grammars fall back
268
+
269
+ private
270
+
271
+ def emit(op, a = nil, b = nil, c = nil) # rubocop:disable Naming/MethodParameterName -- opcode operand slots
272
+ @ops << [op, a, b, c]
273
+ @ops.size - 1
274
+ end
275
+
276
+ def patch(idx, slot, value)
277
+ @ops[idx][slot] = value
278
+ end
279
+
280
+ # Compiler works in instruction indexes; the executor's pc indexes
281
+ # the flattened array (4 slots per instruction).
282
+ def flat(instr_index)
283
+ instr_index * 4
284
+ end
285
+
286
+ # Returns a flat [op, a, b, c, ...] descriptor array when every
287
+ # child compiles to a single-value fused op, else nil.
288
+ def simple_children(parslets, consume_all)
289
+ kids = []
290
+ last = parslets.size - 1
291
+ parslets.each_with_index do |child, idx|
292
+ spine = consume_all && idx == last
293
+ op = simple_child(child, spine)
294
+ return nil if op.nil?
295
+
296
+ kids.concat(op)
297
+ end
298
+ kids
299
+ end
300
+
301
+ def simple_child(atom, spine)
302
+ case atom
303
+ when Parsanol::Atoms::Str
304
+ bytes = atom.str.unpack("C*")
305
+ [STR, bytes, bytes.size, atom.str.bytesize]
306
+ when Parsanol::Atoms::Re
307
+ [RE, atom.re, byte_table(atom.re), nil]
308
+ when Parsanol::Atoms::Repetition
309
+ min = atom.min
310
+ max = atom.max
311
+ return nil if max&.zero? || spine
312
+
313
+ body = atom.parslet
314
+ if min.zero? && max == 1
315
+ case body
316
+ when Parsanol::Atoms::Re
317
+ [MAYBE_RE, body.re, byte_table(body.re), nil]
318
+ when Parsanol::Atoms::Str
319
+ bytes = body.str.unpack("C*")
320
+ [MAYBE_STR, bytes, bytes.size, body.str.bytesize]
321
+ end
322
+ elsif min >= 1 && max.nil?
323
+ case body
324
+ when Parsanol::Atoms::Re
325
+ [RUN_RE, body.re, byte_table(body.re), min]
326
+ when Parsanol::Atoms::Str
327
+ bytes = body.str.unpack("C*")
328
+ [RUN_STR, bytes, bytes.size, min]
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ # Repetition compilation with terminal fusions:
335
+ # * maybe(0,1) of a single terminal -> one MAYBE_* op (no backtrack
336
+ # entry, no unwind cycle on the empty path)
337
+ # * unbounded-plus of a single terminal (non-spine sites) -> greedy
338
+ # RUN_* op
339
+ def compile_repetition(atom, consume_all)
340
+ min = atom.min
341
+ max = atom.max
342
+ return nil if max&.zero?
343
+
344
+ body = atom.parslet
345
+ if min.zero? && max == 1 && !consume_all
346
+ case body
347
+ when Parsanol::Atoms::Re
348
+ emit(MAYBE_RE, body.re, byte_table(body.re), nil)
349
+ return self
350
+ when Parsanol::Atoms::Str
351
+ bytes = body.str.unpack("C*")
352
+ emit(MAYBE_STR, bytes, bytes.size, body.str.bytesize)
353
+ return self
354
+ end
355
+ end
356
+ if min >= 1 && max.nil? && !consume_all
357
+ case body
358
+ when Parsanol::Atoms::Re
359
+ emit(RUN_RE, body.re, byte_table(body.re), min)
360
+ return self
361
+ when Parsanol::Atoms::Str
362
+ bytes = body.str.unpack("C*")
363
+ emit(RUN_STR, bytes, bytes.size, min)
364
+ return self
365
+ end
366
+ end
367
+
368
+ emit(REP_INIT, min, nil, nil)
369
+ test = emit(REP_TEST, nil, max, nil)
370
+ return nil unless compile_atom(body, false)
371
+
372
+ step = emit(REP_STEP, nil, nil, max)
373
+ exit_ = emit(REP_EXIT, atom.result_tag, consume_all ? 1 : 0, nil)
374
+ patch(test, 1, flat(exit_))
375
+ patch(step, 1, flat(test))
376
+ patch(step, 2, flat(exit_))
377
+ self
378
+ end
379
+
380
+ def compile_entity(atom, consume_all)
381
+ body = begin
382
+ atom.parslet
383
+ rescue StandardError
384
+ nil
385
+ end
386
+ return nil if body.nil?
387
+
388
+ # Leaf entities are transparent: a rule whose body is a single
389
+ # terminal compiles inline, skipping the CALL/RET round trip.
390
+ case body
391
+ when Parsanol::Atoms::Str
392
+ bytes = body.str.unpack("C*")
393
+ emit(STR, bytes, bytes.size, body.str.bytesize)
394
+ return self
395
+ when Parsanol::Atoms::Re
396
+ emit(RE, body.re, byte_table(body.re), nil)
397
+ return self
398
+ end
399
+
400
+ # Non-recursive rules inline: the body's ops replace the CALL/RET
401
+ # round trip entirely. A body currently being compiled (directly
402
+ # or indirectly) is recursive and must stay a subroutine.
403
+ # rubocop:disable Lint/HashCompareByIdentity -- object_id keys; would otherwise pin every atom alive
404
+ unless (@compiling ||= {})[body.object_id]
405
+ @compiling[body.object_id] = true
406
+ # rubocop:enable Lint/HashCompareByIdentity
407
+ result = compile_atom(body, consume_all)
408
+ @compiling.delete(body.object_id)
409
+ return result if result
410
+
411
+ return nil
412
+ end
413
+
414
+ key = [body.object_id, consume_all]
415
+ sub = @subs[key]
416
+ if sub.is_a?(Integer)
417
+ emit(CALL, flat(sub), nil, nil)
418
+ else
419
+ call_idx = emit(CALL, nil, nil, nil)
420
+ (@pending[key] ||= []) << [call_idx, body]
421
+ @subs[key] = :pending if sub.nil?
422
+ end
423
+ self
424
+ end
425
+
426
+ def compile_lookahead(atom, consume_all)
427
+ positive = atom.positive
428
+ bound = atom.bound_parslet
429
+ return nil if bound.nil?
430
+
431
+ entry = emit(positive ? LOOK_POS : LOOK_NEG, nil, nil, nil)
432
+ return nil unless compile_atom(bound, consume_all)
433
+
434
+ emit(positive ? LOOK_POS_END : LOOK_NEG_END)
435
+ patch(entry, 1, flat(@ops.size)) # continuation for K_NEG entries
436
+ self
437
+ end
438
+
439
+ # 256-entry table: "regex matches a prefix consisting of exactly this
440
+ # ASCII byte" — only when the regex is provably a single-char class.
441
+ def byte_table(re) # rubocop:disable Naming/MethodParameterName
442
+ return nil unless SINGLE_CLASS_RE.match?(re.source)
443
+ return nil if re.match?("") # can match empty -> not byte-local
444
+
445
+ table = Array.new(256, false)
446
+ (1..127).each do |b|
447
+ table[b] = true if re.match?(b.chr)
448
+ end
449
+ table
450
+ end
451
+ end
452
+
453
+ # One execution of a compiled program over one input string.
454
+ # When a parse succeeds but consumed a large fraction of the step
455
+ # budget, the grammar backtracks heavily and the tree interpreter
456
+ # (with its adaptive memoization) is the better engine; #run reports
457
+ # this via :heavy so Base#parse can skip the VM for the grammar.
458
+ class Executor
459
+ def initialize(program, input, consume_all)
460
+ @ops = program
461
+ @input = input
462
+ @consume_all = consume_all
463
+ end
464
+
465
+ def execute # rubocop:disable Metrics/MethodLength, Metrics/BlockLength, Metrics/BlockNesting -- single dispatch loop; hot path
466
+ ops = @ops
467
+ input = @input
468
+ bytes = input.unpack("C*")
469
+ # Regexp#match?(str, pos) searches FORWARD from pos (unanchored);
470
+ # StringScanner#match? is position-anchored, matching the
471
+ # interpreter's Source#matches? semantics exactly.
472
+ scanner = StringScanner.new(input)
473
+ n = bytes.size
474
+ budget = (STEP_BUDGET_FACTOR * n) + STEP_BUDGET_FIXED
475
+ steps = 0
476
+
477
+ pc = 0
478
+ pos = 0
479
+ rstack = []
480
+ bt = []
481
+ frames = []
482
+ calls = []
483
+
484
+ # rubocop:disable-next Metrics/BlockLength -- the dispatch loop IS execute
485
+ loop do
486
+ steps += 1
487
+ return BAIL if steps > budget
488
+
489
+ if pc == FAIL
490
+ pc, pos = unwind(bt, rstack, frames, calls)
491
+ return [false, nil] if pc == :fail
492
+ return BAIL if pc == :bail
493
+
494
+ next
495
+ end
496
+
497
+ case ops[pc]
498
+ when STR
499
+ lit = ops[pc + 1]
500
+ ln = ops[pc + 2]
501
+ i = 0
502
+ matched = true
503
+ while i < ln
504
+ if bytes[pos + i] != lit[i]
505
+ matched = false
506
+ break
507
+ end
508
+ i += 1
509
+ end
510
+ if matched
511
+ rstack << ((pos << 20) | ln)
512
+ pos += ops[pc + 3]
513
+ pc += 4
514
+ else
515
+ pc = FAIL
516
+ end
517
+ when RE
518
+ b = bytes[pos]
519
+ matched =
520
+ if (table = ops[pc + 2]) && b && b < 128
521
+ table[b]
522
+ elsif pos < n
523
+ (scanner.pos = pos) && scanner.match?(ops[pc + 1])
524
+ else
525
+ false
526
+ end
527
+ if matched
528
+ w = CHAR_WIDTH[b]
529
+ w = char_width_slow(input, pos) if w.zero?
530
+ rstack << ((pos << 20) | w)
531
+ pos += w
532
+ pc += 4
533
+ else
534
+ pc = FAIL
535
+ end
536
+ when ANY
537
+ if pos < n
538
+ w = CHAR_WIDTH[bytes[pos]]
539
+ w = char_width_slow(input, pos) if w.zero?
540
+ rstack << ((pos << 20) | w)
541
+ pos += w
542
+ pc += 4
543
+ else
544
+ pc = FAIL
545
+ end
546
+ when SEQ_BEGIN
547
+ rstack << SEQ_MARK
548
+ pc += 4
549
+ when SEQ_END
550
+ # Pop pushes in reverse; append and single reverse (O(n), no
551
+ # per-element unshift).
552
+ values = []
553
+ guard = rstack.size
554
+ ok_seq = false
555
+ while guard.positive?
556
+ v = rstack.pop
557
+ guard -= 1
558
+ if v == SEQ_MARK
559
+ ok_seq = true
560
+ break
561
+ end
562
+ values << v
563
+ end
564
+ return [false, nil] unless ok_seq
565
+
566
+ values << :sequence
567
+ values.reverse!
568
+ rstack << values
569
+ pc += 4
570
+ when CHOICE
571
+ bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_ALT
572
+ pc += 4
573
+ when POPBT
574
+ bt.pop(BT_STRIDE)
575
+ pc += 4
576
+ when JMP
577
+ pc = ops[pc + 1]
578
+ when NAME_END
579
+ rstack << VM::NamedValue.new(ops[pc + 1], rstack.pop)
580
+ pc += 4
581
+ when CALL
582
+ calls << (pc + 4)
583
+ pc = ops[pc + 1]
584
+ when RET
585
+ pc = calls.pop
586
+ return BAIL if pc.nil?
587
+ when LOOK_POS
588
+ bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_LOOK_FAIL
589
+ pc += 4
590
+ when LOOK_POS_END
591
+ # Body matched: restore entry's saved pos, drop entry and the
592
+ # body's value, push nil like Lookahead#try.
593
+ pos = bt[-BT_STRIDE + 1]
594
+ bt.pop(BT_STRIDE)
595
+ rstack.pop
596
+ rstack << nil
597
+ pc += 4
598
+ when LOOK_NEG
599
+ bt << ops[pc + 1] << pos << rstack.size << frames.size << calls.size << K_NEG
600
+ pc += 4
601
+ when LOOK_NEG_END
602
+ # Body matched: negative lookahead fails. Drop entry + body
603
+ # values, then propagate failure outward.
604
+ rlen = bt[-BT_STRIDE + 2]
605
+ rstack.slice!(rlen..) if rstack.size > rlen
606
+ bt.pop(BT_STRIDE)
607
+ pc = FAIL
608
+ when DROP
609
+ rstack.pop
610
+ rstack << nil
611
+ pc += 4
612
+ when REP_INIT
613
+ frames << 0 << pos << rstack.size << ops[pc + 1] # count,end,rbase,min
614
+ pc += 4
615
+ when REP_TEST
616
+ cbase = frames.size - FRAME_STRIDE
617
+ count = frames[cbase]
618
+ return BAIL if count.nil?
619
+
620
+ max = ops[pc + 2]
621
+ if max && count >= max
622
+ pc = ops[pc + 1] # exit
623
+ else
624
+ bt << ops[pc + 1] << frames[cbase + 1] << rstack.size << cbase << calls.size << K_REP
625
+ pc += 4 # body
626
+ end
627
+ when REP_STEP
628
+ bt.pop(BT_STRIDE)
629
+ cbase = frames.size - FRAME_STRIDE
630
+ count = frames[cbase]
631
+ return BAIL if count.nil?
632
+
633
+ count += 1
634
+ frames[cbase] = count
635
+ frames[cbase + 1] = pos
636
+ max = ops[pc + 3]
637
+ pc = if max && count >= max
638
+ ops[pc + 2] # exit
639
+ else
640
+ ops[pc + 1] # test
641
+ end
642
+ when REP_EXIT
643
+ cbase = frames.size - FRAME_STRIDE
644
+ return BAIL if frames[cbase].nil?
645
+
646
+ if ops[pc + 2] == 1 && pos != n
647
+ frames.slice!(cbase..)
648
+ pc = FAIL
649
+ else
650
+ rbase = frames[cbase + 2]
651
+ values = rstack.pop(rstack.size - rbase)
652
+ values.unshift(ops[pc + 1])
653
+ rstack << values
654
+ frames.slice!(cbase..)
655
+ pc += 4
656
+ end
657
+ when SEQ_SIMPLE
658
+ kids = ops[pc + 1]
659
+ kn = kids.size
660
+ values = [:sequence]
661
+ ki = 0
662
+ failed = false
663
+ while ki < kn
664
+ kop = kids[ki]
665
+ ka = kids[ki + 1]
666
+ kb = kids[ki + 2]
667
+ kc = kids[ki + 3]
668
+ case kop
669
+ when RE
670
+ b = bytes[pos]
671
+ m = if kb && b && b < 128
672
+ kb[b]
673
+ elsif pos < n
674
+ (scanner.pos = pos) && scanner.match?(ka)
675
+ else
676
+ false
677
+ end
678
+ if m
679
+ w = CHAR_WIDTH[b]
680
+ w = char_width_slow(input, pos) if w.zero?
681
+ values << ((pos << 20) | w)
682
+ pos += w
683
+ else
684
+ failed = true
685
+ end
686
+ when STR
687
+ i = 0
688
+ ln = kb
689
+ m = true
690
+ while i < ln
691
+ if bytes[pos + i] != ka[i] # rubocop:disable Metrics/BlockNesting -- fused byte-compare loop
692
+ m = false
693
+ break
694
+ end
695
+ i += 1
696
+ end
697
+ if m
698
+ values << ((pos << 20) | ln)
699
+ pos += kc
700
+ else
701
+ failed = true
702
+ end
703
+ when MAYBE_RE
704
+ b = bytes[pos]
705
+ m = if kb && b && b < 128
706
+ kb[b]
707
+ elsif pos < n
708
+ (scanner.pos = pos) && scanner.match?(ka)
709
+ else
710
+ false
711
+ end
712
+ if m
713
+ w = CHAR_WIDTH[b]
714
+ w = char_width_slow(input, pos) if w.zero?
715
+ values << [:maybe, (pos << 20) | w]
716
+ pos += w
717
+ else
718
+ values << [:maybe]
719
+ end
720
+ when MAYBE_STR
721
+ i = 0
722
+ ln = kb
723
+ m = true
724
+ while i < ln
725
+ if bytes[pos + i] != ka[i] # rubocop:disable Metrics/BlockNesting -- fused byte-compare loop
726
+ m = false
727
+ break
728
+ end
729
+ i += 1
730
+ end
731
+ if m
732
+ values << [:maybe, (pos << 20) | ln]
733
+ pos += kc
734
+ else
735
+ values << [:maybe]
736
+ end
737
+ when RUN_RE
738
+ loop do
739
+ b = bytes[pos]
740
+ m = if kb && b && b < 128
741
+ kb[b]
742
+ elsif pos < n
743
+ (scanner.pos = pos) && scanner.match?(ka)
744
+ else
745
+ false
746
+ end
747
+ break unless m
748
+
749
+ w = CHAR_WIDTH[b]
750
+ w = char_width_slow(input, pos) if w.zero?
751
+ values << ((pos << 20) | w)
752
+ pos += w
753
+ end
754
+ when RUN_STR
755
+ loop do
756
+ i = 0
757
+ ln = kb
758
+ m = true
759
+ while i < ln
760
+ if bytes[pos + i] != ka[i] # rubocop:disable Metrics/BlockNesting -- fused byte-compare loop
761
+ m = false
762
+ break
763
+ end
764
+ i += 1
765
+ end
766
+ break unless m
767
+
768
+ values << ((pos << 20) | ln)
769
+ pos += ln
770
+ end
771
+ end
772
+ break if failed
773
+
774
+ ki += 4
775
+ end
776
+ if failed
777
+ pc = FAIL
778
+ else
779
+ rstack << values
780
+ pc += 4
781
+ end
782
+ when MAYBE_RE
783
+ b = bytes[pos]
784
+ matched =
785
+ if (table = ops[pc + 2]) && b && b < 128
786
+ table[b]
787
+ elsif pos < n
788
+ (scanner.pos = pos) && scanner.match?(ops[pc + 1])
789
+ else
790
+ false
791
+ end
792
+ if matched
793
+ w = CHAR_WIDTH[b]
794
+ w = char_width_slow(input, pos) if w.zero?
795
+ rstack << [:maybe, (pos << 20) | w]
796
+ pos += w
797
+ else
798
+ rstack << [:maybe]
799
+ end
800
+ pc += 4
801
+ when MAYBE_STR
802
+ lit = ops[pc + 1]
803
+ ln = ops[pc + 2]
804
+ i = 0
805
+ matched = true
806
+ while i < ln
807
+ if bytes[pos + i] != lit[i]
808
+ matched = false
809
+ break
810
+ end
811
+ i += 1
812
+ end
813
+ if matched
814
+ rstack << [:maybe, (pos << 20) | ln]
815
+ pos += ops[pc + 3]
816
+ else
817
+ rstack << [:maybe]
818
+ end
819
+ pc += 4
820
+ when RUN_RE
821
+ re = ops[pc + 1]
822
+ table = ops[pc + 2]
823
+ min = ops[pc + 3]
824
+ values = [:repetition]
825
+ loop do
826
+ b = bytes[pos]
827
+ m = if table && b && b < 128
828
+ table[b]
829
+ elsif pos < n
830
+ (scanner.pos = pos) && scanner.match?(re)
831
+ else
832
+ false
833
+ end
834
+ break unless m
835
+
836
+ w = CHAR_WIDTH[b]
837
+ w = char_width_slow(input, pos) if w.zero?
838
+ values << ((pos << 20) | w)
839
+ pos += w
840
+ end
841
+ if min && values.size - 1 < min
842
+ pc = FAIL
843
+ else
844
+ rstack << values
845
+ pc += 4
846
+ end
847
+ when RUN_STR
848
+ lit = ops[pc + 1]
849
+ ln = ops[pc + 2]
850
+ min = ops[pc + 3]
851
+ values = [:repetition]
852
+ loop do
853
+ i = 0
854
+ matched = true
855
+ while i < ln
856
+ if bytes[pos + i] != lit[i]
857
+ matched = false
858
+ break
859
+ end
860
+ i += 1
861
+ end
862
+ break unless matched
863
+
864
+ values << ((pos << 20) | ln)
865
+ pos += ln
866
+ end
867
+ if min && values.size - 1 < min
868
+ pc = FAIL
869
+ else
870
+ rstack << values
871
+ pc += 4
872
+ end
873
+ when HALT
874
+ if @consume_all && pos != n
875
+ pc = FAIL
876
+ next
877
+ end
878
+ value = VM.materialize(rstack[0], input)
879
+ # More than ~100 steps per input byte means heavy
880
+ # backtracking; the memoizing interpreter wins there.
881
+ return steps > (n << 9) + 1000 ? [:heavy, value] : [true, value]
882
+ else
883
+ return BAIL
884
+ end
885
+ end
886
+ end
887
+
888
+ private
889
+
890
+ # Unwind one backtrack entry. Returns [pc, pos] or :fail. Repetition
891
+ # entries with count >= min complete their tagged array and jump to
892
+ # the exit continuation with the last iteration end position.
893
+ def unwind(bt, rstack, frames, calls) # rubocop:disable Naming/MethodParameterName -- stack register names
894
+ return :fail if bt.empty?
895
+
896
+ # Everything above this entry belongs to constructs being
897
+ # abandoned by the jump (inner alternatives, repetitions, or
898
+ # subroutine state); discard it along with the entry itself.
899
+ blen = bt.size - BT_STRIDE
900
+ if ENV["VM_TRACE"]
901
+ warn " UNWIND bt=#{bt.each_slice(6).map { |e| "[#{e[0]},#{e[1]},#{e[2]},#{e[3]},#{e[4]},#{e[5]}" }.join(' ')}"
902
+ end
903
+ kind = bt.pop
904
+ clen = bt.pop
905
+ cbase = bt.pop
906
+ rlen = bt.pop
907
+ epos = bt.pop
908
+ epc = bt.pop
909
+ bt.slice!(blen..) if bt.size > blen
910
+
911
+ rstack.slice!(rlen..) if rstack.size > rlen
912
+ calls.slice!(clen..) if calls.size > clen
913
+
914
+ case kind
915
+ when K_ALT
916
+ frames.slice!(cbase..) if frames.size > cbase
917
+ [epc, epos]
918
+ when K_REP
919
+ count = frames[cbase]
920
+ min = frames[cbase + 3]
921
+ if count.nil? || min.nil?
922
+ return :bail
923
+ end
924
+
925
+ if count >= min
926
+ # Repetition succeeds with the completed prefix.
927
+ rbase = frames[cbase + 2]
928
+ end_pos = frames[cbase + 1]
929
+ tag = @ops[epc + 1]
930
+ check_full = @ops[epc + 2]
931
+ if check_full == 1 && end_pos != @input.bytesize
932
+ frames.slice!(cbase..)
933
+ unwind(bt, rstack, frames, calls)
934
+ else
935
+ values = rstack.pop(rstack.size - rbase)
936
+ values.unshift(tag)
937
+ rstack << values
938
+ frames.slice!(cbase..)
939
+ [epc + 4, end_pos]
940
+ end
941
+ else
942
+ frames.slice!(cbase..)
943
+ unwind(bt, rstack, frames, calls)
944
+ end
945
+ when K_LOOK_FAIL
946
+ frames.slice!(cbase..) if frames.size > cbase
947
+ unwind(bt, rstack, frames, calls)
948
+ when K_NEG
949
+ # Negative lookahead body failed -> lookahead succeeds.
950
+ frames.slice!(cbase..) if frames.size > cbase
951
+ rstack << nil
952
+ [epc, epos]
953
+ end
954
+ end
955
+
956
+ def char_width_slow(input, pos)
957
+ w = CHAR_WIDTH[input.getbyte(pos)]
958
+ w.zero? ? 1 : w
959
+ end
960
+ end
961
+ end
962
+ end
data/lib/parsanol.rb CHANGED
@@ -259,6 +259,7 @@ require "parsanol/atoms"
259
259
  require "parsanol/pattern"
260
260
  require "parsanol/pattern/binding"
261
261
  require "parsanol/transform"
262
+ require "parsanol/vm"
262
263
  require "parsanol/parser"
263
264
  require "parsanol/error_reporter"
264
265
  require "parsanol/scope"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.14
4
+ version: 1.3.15
5
5
  platform: arm-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -238,6 +238,7 @@ files:
238
238
  - lib/parsanol/string_view.rb
239
239
  - lib/parsanol/transform.rb
240
240
  - lib/parsanol/version.rb
241
+ - lib/parsanol/vm.rb
241
242
  - lib/parsanol/wasm/README.md
242
243
  - lib/parsanol/wasm/package.json
243
244
  - lib/parsanol/wasm/parsanol.js