parsanol 1.3.53-aarch64-linux → 1.3.56-aarch64-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: 78cdeee79e9f782adaf7fbf1a179a3544d7586824fc58f92b380f7ad8867e050
4
- data.tar.gz: f5052cd53addef5e182fa256dff390ad1242199bedf59c7ee19b998b648b42e7
3
+ metadata.gz: 06407307b9391eecc790b0d6364e2643ee15a1cee425233af4e66c119cde1c5e
4
+ data.tar.gz: 39627457a04894edc05c20599906995061ec110a6153197c3dd7b03a05b9e327
5
5
  SHA512:
6
- metadata.gz: 49a0178ef44d9b294afe7274219d130fc9a53350f4d0af505b527a2eac2c94c60803334a378239466bc4950649a71999338bddab81e76b97372d84e15057becd
7
- data.tar.gz: b935fc4ed2ebecae8960d3f35f5ba142dea1e24c6b9927cb7f50b6d9b16713ae58db6de5b99010c8c4431b26385b398563b6ac30be8d098a043e9a0831dc27c1
6
+ metadata.gz: 20e45402ce4f15e0d0f711498627f2c6c0b3f78723806a84b3ac2be1e37868bdeb817c7c5c196622e4eb4f0f662971b9cd87b50d48ac04bfa57578d424ca11b7
7
+ data.tar.gz: b63db5788cce9c5ecb96a08b1c3301a97a5f1862039ff3c968b453b724088c055be887652f59933db9bd2c3499e6e46b9b8ec11666c3caa67c2fc6798791098a
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.2/parsanol_native.so
3
- size: 34576408 bytes
4
- sha256: 5b8639e8add801e38778fc09193751011ee6b3fd7db507b9ac630ca8aa06046c
3
+ size: 34626704 bytes
4
+ sha256: 0976c692128d3f93b042159f46dc09f21e93d48e98e2004712c31f001ce6ab6b
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.3/parsanol_native.so
3
- size: 34819592 bytes
4
- sha256: 9697c96e0b02cdc86ed015e415b22d1e87f7f645008f81d4472b1f21b4154612
3
+ size: 34861544 bytes
4
+ sha256: c0e30ff6b7cdfd74a0d7dd07b2aaa0b4381585e02162bcb04ce786eee748726a
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.4/parsanol_native.so
3
- size: 34807048 bytes
4
- sha256: 43ef924415edbbafe3891e295ace6b289ef9ee3e744d6e84a75894538e7482a0
3
+ size: 34857328 bytes
4
+ sha256: 2380c24bf2c89e71dcf7521934b5e5bc57366fe0c00106d8aed715eaddbabd99
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/4.0/parsanol_native.so
3
- size: 34817024 bytes
4
- sha256: d70917dabf06b21743261e8b70ceea32e2bd8795f3604b86cc40bdacacd9e2ad
3
+ size: 34867336 bytes
4
+ sha256: f43381689aa3e99ff0c2bedd7611874e370344947576335c34a8a4cec2153cd7
@@ -208,7 +208,7 @@ module Parsanol
208
208
 
209
209
  # Converts raw input to Source if needed.
210
210
  def normalize_input(source)
211
- source.respond_to?(:line_and_column) ? source : Parsanol::Source.new(source)
211
+ source.is_a?(Parsanol::Source) ? source : Parsanol::Source.new(source)
212
212
  end
213
213
 
214
214
  # Detects if we're in a Parser context.
@@ -42,7 +42,7 @@ module Parsanol
42
42
  # 1. Mark the current position as a cut point
43
43
  # 2. Empty the backtrack stack (we won't backtrack past here)
44
44
  # 3. Aggressively evict cache entries before this position
45
- context.cut!(source.bytepos) if context.respond_to?(:cut!)
45
+ context.cut!(source.bytepos)
46
46
 
47
47
  [success, value]
48
48
  end
@@ -66,7 +66,7 @@ module Parsanol
66
66
  # @return [Array(Boolean, Object)] result
67
67
  def try(source, context, consume_all)
68
68
  # Check for tree memoization support
69
- if context.respond_to?(:use_tree_memoization?) && context.use_tree_memoization?
69
+ if context.use_tree_memoization?
70
70
  return with_tree_cache(source, context, consume_all)
71
71
  end
72
72
 
@@ -183,6 +183,7 @@ module Parsanol
183
183
  last_failure = nil
184
184
 
185
185
  loop do
186
+ iteration_start = source.bytepos
186
187
  success, value = @parslet.apply(source, context, false)
187
188
 
188
189
  unless success
@@ -193,6 +194,11 @@ module Parsanol
193
194
  occurrence += 1
194
195
  result[occurrence] = value
195
196
 
197
+ # A body match that consumes nothing cannot make progress;
198
+ # looping again would re-match empty forever. Count the empty
199
+ # match, then stop — the min check below applies as usual.
200
+ break if source.bytepos == iteration_start
201
+
196
202
  break if @max && occurrence >= @max
197
203
  end
198
204
 
@@ -254,6 +260,7 @@ module Parsanol
254
260
  last_failure = nil
255
261
 
256
262
  loop do
263
+ iteration_start = source.bytepos
257
264
  success, value = @parslet.apply(source, context, false)
258
265
 
259
266
  unless success
@@ -265,6 +272,10 @@ module Parsanol
265
272
  result[occurrence] = value
266
273
  positions << source.bytepos
267
274
 
275
+ # Zero-width body match: count it, stop iterating (the min
276
+ # check below applies as usual) — mirrors try_general.
277
+ break if source.bytepos == iteration_start
278
+
268
279
  break if @max && occurrence >= @max
269
280
  end
270
281
 
@@ -287,7 +298,7 @@ module Parsanol
287
298
  context.store_tree_memo(cache_key, start_pos,
288
299
  result[1, occurrence], end_pos)
289
300
  end
290
- context.release_array(positions) if context.respond_to?(:release_array)
301
+ context.release_array(positions)
291
302
 
292
303
  # Check minimum
293
304
  if occurrence < @min
@@ -74,7 +74,7 @@ module Parsanol
74
74
  line_num, col_num = @source.line_and_column(@position)
75
75
 
76
76
  formatted_msg = @message.map do |msg|
77
- msg.respond_to?(:to_slice) ? msg.content.inspect : msg.to_s
77
+ msg.is_a?(Parsanol::Slice) ? msg.content.inspect : msg.to_s
78
78
  end.join
79
79
 
80
80
  "#{formatted_msg} at line #{line_num} char #{col_num}#{@parsing_label}."
@@ -72,7 +72,7 @@ module Parsanol
72
72
  cause = super
73
73
 
74
74
  # Apply label if the atom has one
75
- if atom.respond_to?(:label) && (lbl = atom.label)
75
+ if atom.is_a?(Parsanol::Atoms::Base) && (lbl = atom.label)
76
76
  maybe_update_label(lbl, src.pos.bytepos)
77
77
  cause.set_label(@active_label_text)
78
78
  end
@@ -103,6 +103,15 @@ module Parsanol
103
103
 
104
104
  alias kind_of? is_a?
105
105
 
106
+ # The delegated interface: Array's full class contract (including
107
+ # Enumerable), computed once against the type rather than probed on
108
+ # instances.
109
+ ARRAY_METHODS = (
110
+ Array.instance_methods(true) + Enumerable.instance_methods + [:to_ary]
111
+ ).freeze
112
+
113
+ private_constant :ARRAY_METHODS
114
+
106
115
  # Respond to array methods.
107
116
  #
108
117
  # @param method [Symbol] Method name
@@ -110,7 +119,7 @@ module Parsanol
110
119
  # @return [Boolean] true if responds
111
120
  #
112
121
  def respond_to?(method, include_private = false)
113
- super || to_a.respond_to?(method, include_private)
122
+ super || ARRAY_METHODS.include?(method)
114
123
  end
115
124
 
116
125
  # Delegate unknown methods to materialized array.
@@ -121,7 +130,7 @@ module Parsanol
121
130
  # @return [Object] Result of method call
122
131
  #
123
132
  def method_missing(method, ...)
124
- if to_a.respond_to?(method)
133
+ if ARRAY_METHODS.include?(method)
125
134
  to_a.public_send(method, ...)
126
135
  else
127
136
  super
@@ -135,7 +144,7 @@ module Parsanol
135
144
  # @return [Boolean] true if method is supported
136
145
  #
137
146
  def respond_to_missing?(method, include_private = false)
138
- to_a.respond_to?(method, include_private) || super
147
+ ARRAY_METHODS.include?(method) || super
139
148
  end
140
149
 
141
150
  # Compare with another object.
Binary file
@@ -391,15 +391,19 @@ module Parsanol
391
391
  return left.is_a?(Hash) ? left.merge(right) : left + right
392
392
  end
393
393
 
394
- if left.respond_to?(:to_str) && right.respond_to?(:to_str)
395
- return right if right.respond_to?(:to_slice)
396
- return left if left.respond_to?(:to_slice)
397
-
398
- return left.to_str + right.to_str
394
+ # String-like = String or Slice (both implement to_str). When
395
+ # only ONE side is string-like, the OTHER side wins — the
396
+ # original duck-typed priority, preserved exactly.
397
+ left_stringlike = left.is_a?(String) || left.is_a?(Parsanol::Slice)
398
+ right_stringlike = right.is_a?(String) || right.is_a?(Parsanol::Slice)
399
+ if left_stringlike && right_stringlike
400
+ return right if right.is_a?(Parsanol::Slice)
401
+ return left if left.is_a?(Parsanol::Slice)
402
+
403
+ return left + right
399
404
  end
400
-
401
- return left if right.respond_to?(:to_str)
402
- return right if left.respond_to?(:to_str)
405
+ return left if right_stringlike
406
+ return right if left_stringlike
403
407
 
404
408
  return left + [right] if right.is_a?(Hash)
405
409
  return [left] + right if left.is_a?(Hash)
@@ -216,7 +216,9 @@ module Parsanol
216
216
  # cause from that directly — the failure path never needs a
217
217
  # reporter reparse. The single interpreter pass below stays only
218
218
  # to recover inputs from grammars native cannot express.
219
- if grammar.respond_to?(:parse) && (m = error.message.match(NATIVE_POS_MARKER))
219
+ parsanol_grammar =
220
+ grammar.is_a?(Parsanol::Atoms::Base) || grammar.is_a?(Parsanol::Parser)
221
+ if parsanol_grammar && (m = error.message.match(NATIVE_POS_MARKER))
220
222
  source = Parsanol::Source.new(input)
221
223
  success, value = grammar.run_with_context(source, nil, true)
222
224
  return grammar.finalize_result(value) if success
@@ -227,7 +229,7 @@ module Parsanol
227
229
  raise Parsanol::ParseFailed.new(cause.to_s, cause)
228
230
  end
229
231
 
230
- if grammar.respond_to?(:parse)
232
+ if parsanol_grammar
231
233
  # No native diagnostics (e.g. incomplete-input errors): one
232
234
  # interpreter pass with the reporter attached — a success
233
235
  # recovers the tree, a failure raises the cause tree.
@@ -255,7 +255,7 @@ module Parsanol
255
255
  # already produced. The full per-atom event stream of the Ruby
256
256
  # engine remains available via an explicit `mode: :ruby`.
257
257
  def feed_native_reporter(reporter, input, error)
258
- return unless reporter.respond_to?(:err_at)
258
+ return unless reporter.is_a?(Parsanol::ErrorReporter::Base)
259
259
 
260
260
  source = Parsanol::Source.new(input)
261
261
  cause = error.parse_failure_cause
@@ -90,7 +90,7 @@ module Parsanol
90
90
  return true if pattern_val === target
91
91
 
92
92
  # Check if pattern is a binding expression (like simple(:x))
93
- if pattern_val.respond_to?(:can_bind?) && pattern_val.can_bind?(target)
93
+ if pattern_val.is_a?(Parsanol::Pattern::SubtreeBind) && pattern_val.can_bind?(target)
94
94
  return capture_binding(target, pattern_val, captured)
95
95
  end
96
96
 
data/lib/parsanol/pool.rb CHANGED
@@ -119,7 +119,7 @@ module Parsanol
119
119
  end
120
120
 
121
121
  # Reset object state if it supports the protocol
122
- obj.reset! if obj.respond_to?(:reset!)
122
+ obj.reset! if obj.is_a?(Parsanol::Resettable)
123
123
 
124
124
  @stats[:released] += 1
125
125
  @available.push(obj)
data/lib/parsanol/rope.rb CHANGED
@@ -67,7 +67,7 @@ module Parsanol
67
67
  #
68
68
  # @return [Integer] The sum of all segment sizes
69
69
  def size
70
- @segments.sum { |s| s.respond_to?(:size) ? s.size : s.to_s.size }
70
+ @segments.sum(&:size)
71
71
  end
72
72
 
73
73
  # Creates a rope from an existing string.
@@ -142,18 +142,16 @@ module Parsanol
142
142
 
143
143
  # Unified line/column computation:
144
144
  # - String input: compute from input string
145
- # - LineCache: delegate to cache
145
+ # - Source / LineCache: delegate
146
146
  def line_and_column_at(pos)
147
- if @input.respond_to?(:line_and_column)
148
- # LineCache or duck-typed object
149
- @input.line_and_column(pos)
150
- else
151
- # String input
147
+ if @input.is_a?(String)
152
148
  prefix = @input.byteslice(0, pos) || ""
153
149
  line = 1 + prefix.count("\n")
154
150
  last_nl = prefix.rindex("\n")
155
151
  column = last_nl ? pos - last_nl : pos + 1
156
152
  [line, column]
153
+ else
154
+ @input.line_and_column(pos)
157
155
  end
158
156
  end
159
157
  end
@@ -34,7 +34,7 @@ module Parsanol
34
34
  def line_and_column(position)
35
35
  scan_buffer_once
36
36
 
37
- position = position.bytepos if position.respond_to?(:bytepos)
37
+ position = position.bytepos if position.is_a?(Parsanol::Source)
38
38
 
39
39
  line_idx = @breaks.lower_bound_index(position)
40
40
 
@@ -33,9 +33,9 @@ module Parsanol
33
33
  # @raise [ArgumentError] if input doesn't respond to to_str
34
34
  #
35
35
  def initialize(input)
36
- unless input.respond_to?(:to_str)
36
+ unless input.is_a?(String)
37
37
  raise ArgumentError,
38
- "Source requires a string-like object (responds to to_str)"
38
+ "Source requires a String (got #{input.class})"
39
39
  end
40
40
 
41
41
  @raw_string = input.to_str
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.53"
4
+ VERSION = "1.3.56"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -149,6 +149,7 @@ module Parsanol
149
149
  def compile_with_inline(atom, inline)
150
150
  root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
151
151
  compiler = Compiler.new(inline: inline)
152
+ validate_alternatives(root)
152
153
  return nil unless compiler.compile_atom(root, true)
153
154
 
154
155
  # HALT terminates the MAIN program; subroutines follow it so the
@@ -156,9 +157,99 @@ module Parsanol
156
157
  compiler.ops << [HALT, nil, nil, nil]
157
158
  return nil unless compiler.append_subroutines
158
159
 
160
+ # Terminal failures jump to pc = FAIL without testing the pc
161
+ # register on the hot path; the dispatch case instead lands on
162
+ # this dedicated instruction (the last slot in the flat program),
163
+ # whose opcode is FAIL itself.
164
+ compiler.ops << [FAIL, nil, nil, nil]
165
+
166
+ # Structure-seeded memoization: a grammar containing repeat/maybe
167
+ # starts memoized on its very first parse (fresh parser instances
168
+ # included — the seed is recomputed at every compile), so the
169
+ # cold-start never pays the doomed unmemoized exploration.
170
+ if compiler.backtracking_prone
171
+ # rubocop:disable-next Lint/HashCompareByIdentity -- object_id keys match run_for's heavy flag; would otherwise pin every grammar alive
172
+ (@heavy ||= {})[root.object_id] = true
173
+ end
174
+
159
175
  compiler.to_program
160
176
  end
161
177
 
178
+ def validate_choice_branches(atom)
179
+ atom.alternatives.each_with_index do |branch, idx|
180
+ walk_branch = branch
181
+ next if idx == atom.alternatives.size - 1
182
+
183
+ # Entities resolve lazily and may be recursive; resolve
184
+ # through the wrapper for the nullability check only.
185
+ while walk_branch.is_a?(Parsanol::Atoms::Entity) ||
186
+ walk_branch.is_a?(Parsanol::Atoms::Named)
187
+ walk_branch = begin
188
+ walk_branch.parslet
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ break if walk_branch.nil?
193
+ end
194
+ next if walk_branch.nil?
195
+ next unless Compiler.new.nullable?(walk_branch)
196
+
197
+ raise Parsanol::GrammarError,
198
+ "wrong grammar: alternative branch #{idx} " \
199
+ "(#{branch.inspect}) can match empty input and is not " \
200
+ "the last branch — ordered choice commits to its empty " \
201
+ "match, so #{atom.alternatives.size - idx - 1} later " \
202
+ "branch(es) can never match at positions where it " \
203
+ "matches empty. Reorder branches so empty-matchable " \
204
+ "ones come last, or make the branch non-empty."
205
+ end
206
+ end
207
+
208
+ # Grammar validation, run once per compile: an ordered-choice
209
+ # branch that can match empty input while not being last commits
210
+ # to its empty match and permanently shadows every later branch —
211
+ # the grammar is wrong, and inputs it would mis-parse fail with a
212
+ # baffling cause. Raise before any input is seen.
213
+ def validate_alternatives(root)
214
+ visited = {}.compare_by_identity
215
+ walk = lambda do |atom, depth|
216
+ next if atom.nil? || depth > 20
217
+ next if visited[atom]
218
+
219
+ visited[atom] = true
220
+ case atom
221
+ when Parsanol::Atoms::Alternative
222
+ validate_choice_branches(atom)
223
+ atom.alternatives.each { |c| walk.call(c, depth + 1) }
224
+ when Parsanol::Atoms::Sequence
225
+ atom.parslets.each { |c| walk.call(c, depth + 1) }
226
+ when Parsanol::Atoms::Repetition, Parsanol::Atoms::Named
227
+ walk.call(atom.parslet, depth + 1)
228
+ when Parsanol::Atoms::Lookahead
229
+ walk.call(atom.bound_parslet, depth + 1)
230
+ when Parsanol::Atoms::Ignored
231
+ walk.call(atom.wrapped_atom, depth + 1)
232
+ when Parsanol::Atoms::Entity
233
+ begin
234
+ walk.call(atom.parslet, depth + 1)
235
+ rescue Parsanol::GrammarError
236
+ raise
237
+ rescue StandardError
238
+ # Lazily resolved / recursive entity that cannot resolve
239
+ # outside a parse: skip this branch of the walk.
240
+ nil
241
+ end
242
+ when Parsanol::Atoms::Scope
243
+ begin
244
+ walk.call(atom.block.call, depth + 1)
245
+ rescue StandardError
246
+ nil
247
+ end
248
+ end
249
+ end
250
+ walk.call(root, 0)
251
+ end
252
+
162
253
  # Executes a compiled program. Returns [true, value] on success;
163
254
  # [false, nil] on failure or budget exhaustion — callers fall back
164
255
  # to the interpreter, which reproduces exact diagnostics.
@@ -248,9 +339,10 @@ module Parsanol
248
339
  @inline = inline
249
340
  @subs = {} # [obj_id, flag] => pc or :pending
250
341
  @pending = {} # [obj_id, flag] => [[call_idx, body], ...]
342
+ @backtracking_prone = false
251
343
  end
252
344
 
253
- attr_reader :ops
345
+ attr_reader :ops, :backtracking_prone
254
346
 
255
347
  def to_program
256
348
  return :oversize if @ops.size > MAX_PROGRAM
@@ -608,6 +700,11 @@ module Parsanol
608
700
  # * unbounded-plus of a single terminal (non-spine sites) -> greedy
609
701
  # RUN_* op
610
702
  def compile_repetition(atom, consume_all)
703
+ # Any repetition (repeat, maybe, repeat(0)) can re-explore the
704
+ # same rule+position pairs when a later sibling fails; grammars
705
+ # containing one memoize from the first parse instead of waiting
706
+ # for the naive pass to prove the need.
707
+ @backtracking_prone = true
611
708
  min = atom.min
612
709
  max = atom.max
613
710
  return nil if max&.zero?
@@ -737,6 +834,10 @@ module Parsanol
737
834
  def execute # rubocop:disable Metrics/MethodLength, Metrics/BlockLength, Metrics/BlockNesting -- single dispatch loop; hot path
738
835
  ops = @ops
739
836
  input = @input
837
+ # Dedicated FAIL landing instruction appended after HALT and the
838
+ # subroutines: `pc = fail_pc` dispatches straight into when-FAIL
839
+ # on the next cycle, no sentinel comparison per step.
840
+ fail_pc = ops.size - 4
740
841
  bytes = input.unpack("C*")
741
842
  # Regexp#match?(str, pos) searches FORWARD from pos (unanchored);
742
843
  # StringScanner#match? is position-anchored, matching the
@@ -800,7 +901,7 @@ module Parsanol
800
901
  pos += ops[pc + 3]
801
902
  pc += 4
802
903
  else
803
- pc = FAIL
904
+ pc = fail_pc
804
905
  end
805
906
  when RE
806
907
  b = bytes[pos]
@@ -819,7 +920,7 @@ module Parsanol
819
920
  pos += w
820
921
  pc += 4
821
922
  else
822
- pc = FAIL
923
+ pc = fail_pc
823
924
  end
824
925
  when ANY
825
926
  if pos < n
@@ -829,7 +930,7 @@ module Parsanol
829
930
  pos += w
830
931
  pc += 4
831
932
  else
832
- pc = FAIL
933
+ pc = fail_pc
833
934
  end
834
935
  when SEQ_BEGIN
835
936
  rstack << SEQ_MARK
@@ -878,7 +979,7 @@ module Parsanol
878
979
  # PENDING — re-entry at the same rule+position (left
879
980
  # recursion); the interpreter loops forever here, so
880
981
  # failing this path keeps the VM bounded.
881
- pc = FAIL
982
+ pc = fail_pc
882
983
  next
883
984
  end
884
985
  # Memo hit: replay the subroutine's stack effect without
@@ -926,7 +1027,7 @@ module Parsanol
926
1027
  rlen = bt[-BT_STRIDE + 2]
927
1028
  rstack.slice!(rlen..) if rstack.size > rlen
928
1029
  bt.pop(BT_STRIDE)
929
- pc = FAIL
1030
+ pc = fail_pc
930
1031
  when DROP
931
1032
  rstack.pop
932
1033
  rstack << nil
@@ -952,12 +1053,24 @@ module Parsanol
952
1053
  count = frames[cbase]
953
1054
  return BAIL if count.nil?
954
1055
 
1056
+ old_end = frames[cbase + 1]
955
1057
  count += 1
956
1058
  frames[cbase] = count
957
1059
  frames[cbase + 1] = pos
958
1060
  max = ops[pc + 3]
959
1061
  pc = if max && count >= max
960
1062
  ops[pc + 2] # exit
1063
+ elsif pos == old_end
1064
+ # Zero-width body match: count it, stop iterating.
1065
+ # Mirrors Repetition#try_general: success with the
1066
+ # prefix when min is met, failure of the repetition
1067
+ # otherwise.
1068
+ if count >= frames[cbase + 3]
1069
+ ops[pc + 2] # exit
1070
+ else
1071
+ frames.slice!(cbase..)
1072
+ fail_pc
1073
+ end
961
1074
  else
962
1075
  ops[pc + 1] # test
963
1076
  end
@@ -967,7 +1080,7 @@ module Parsanol
967
1080
 
968
1081
  if ops[pc + 2] == 1 && pos != n
969
1082
  frames.slice!(cbase..)
970
- pc = FAIL
1083
+ pc = fail_pc
971
1084
  else
972
1085
  rbase = frames[cbase + 2]
973
1086
  values = rstack.pop(rstack.size - rbase)
@@ -980,7 +1093,7 @@ module Parsanol
980
1093
  table = ops[pc + 1]
981
1094
  b = pos < n ? bytes[pos] : -1
982
1095
  target = b == -1 ? -1 : table[b]
983
- pc = target >= 0 ? target : FAIL
1096
+ pc = target >= 0 ? target : fail_pc
984
1097
  when SEQ_SIMPLE
985
1098
  kids = ops[pc + 1]
986
1099
  kn = kids.size
@@ -1115,7 +1228,7 @@ module Parsanol
1115
1228
  ki += 4
1116
1229
  end
1117
1230
  if failed
1118
- pc = FAIL
1231
+ pc = fail_pc
1119
1232
  else
1120
1233
  rstack << values
1121
1234
  pc += 4
@@ -1180,7 +1293,7 @@ module Parsanol
1180
1293
  pos += w
1181
1294
  end
1182
1295
  if min && values.size - 1 < min
1183
- pc = FAIL
1296
+ pc = fail_pc
1184
1297
  else
1185
1298
  rstack << values
1186
1299
  pc += 4
@@ -1206,14 +1319,14 @@ module Parsanol
1206
1319
  pos += ln
1207
1320
  end
1208
1321
  if min && values.size - 1 < min
1209
- pc = FAIL
1322
+ pc = fail_pc
1210
1323
  else
1211
1324
  rstack << values
1212
1325
  pc += 4
1213
1326
  end
1214
1327
  when HALT
1215
1328
  if @consume_all && pos != n
1216
- pc = FAIL
1329
+ pc = fail_pc
1217
1330
  next
1218
1331
  end
1219
1332
  value = VM.materialize(rstack[0], input)
data/lib/parsanol.rb CHANGED
@@ -44,6 +44,12 @@ module Parsanol
44
44
  base.extend(ClassMethods)
45
45
  end
46
46
 
47
+ # Raised when the GRAMMAR itself is malformed — detected before any
48
+ # input is parsed. The canonical case: an ordered-choice branch that
49
+ # can match empty input while not being last, which commits to its
50
+ # empty match and permanently shadows every later branch.
51
+ class GrammarError < StandardError; end
52
+
47
53
  # Exception raised when parsing fails. Contains detailed error information
48
54
  # in the #parse_failure_cause attribute.
49
55
  class ParseFailed < StandardError
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.53
4
+ version: 1.3.56
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A small Ruby library for constructing parsers in the PEG (Parsing Expression
14
14
  Grammar) fashion. Parsanol provides Parslet-compatible API with additional features