parsanol 1.3.114-arm-linux → 1.3.116-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: 9ccfe53145a4fbf2af4f7c2cc47071231be0016d8047e9bb0f75386738ff0480
4
- data.tar.gz: 395d7bccac544d4842b29e5764d1198816ab823e1f9defdc3874009395caaa24
3
+ metadata.gz: 53a5e42c7662bba04ee275b1679b942f05064c09ded4a8d0108b7fa1a4a6ca6a
4
+ data.tar.gz: 7c14ce10502509c398b7303dab5cb11962a6ff8a4b8108ed94fb23aa88f7bbce
5
5
  SHA512:
6
- metadata.gz: b1436b7af7ea025b302e14fc14970c42252c75f5aa6a2252f1701862ebca604ccc05349a39fdbd77b09fef78c7e633b9a6ff15c3f22e3a36845f49133a84eb7a
7
- data.tar.gz: 188fe5c41d516ab6f3372ca339d0c313b0460e912e122a9e32ed5f8b948deb743c3a38f406997dddbb97cc9e3a5a7a25587640273eb44c97dde75d518d24ad6b
6
+ metadata.gz: 1d49c7d42d94edc62d2e7503650bc59d8d2e40c6f067aed05fe154bea4a24a90b2aea6c096110f7cc3b8517dfda73a22c21fdb6c272783df35517a6b57d9da09
7
+ data.tar.gz: b1373d5e437f0ed0e3caccebea3658bed5ba52036eb56ec65c1a4f1fd98026428b565047178032a4f607e34e7735fe3664f9c826421bb11ba8111a22702c0dd3
data/README.adoc CHANGED
@@ -31,6 +31,17 @@ While maintaining full API compatibility with Parslet, Parsanol features a compl
31
31
  * <<debug-tools,Debug Tools>> - Tracing and grammar visualization
32
32
  * <<benchmarking,Benchmarking>> - Built-in performance testing tools
33
33
 
34
+ == Releases
35
+
36
+ parsanol is one product on **four runtimes**: **rs** (crates.io +
37
+ `@parsanol/wasm` + release binaries), **ruby** (rubygems), **python**
38
+ (PyPI, `parsanol`), and **typescript** (npm, `parsanol` — the
39
+ wasm-backed runtime). **Every engine update releases all four
40
+ together**; a release that ships some runtimes and not the rest is
41
+ incomplete. If the runtime you use lags the latest grammar-language
42
+ features, check the other registries' changelogs and the release train
43
+ on the https://github.com/parsanol/parsanol-ruby/actions[Release workflow].
44
+
34
45
  == Installation
35
46
 
36
47
  Add this line to your application's Gemfile:
@@ -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: 39082700 bytes
4
- sha256: d252dd306fb0dcb4726ed8a1deb826cecfc1ca8bf5528603f50ac20058e0aea9
3
+ size: 39369156 bytes
4
+ sha256: f4664338d4a5f66572c33e06d923912d35f98bce860d2dcc8379f065622c6e29
@@ -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: 39267920 bytes
4
- sha256: 4f500faf69c8b85dff0055043e37d08bdb897cabd0bc814ce1a5a7f31db0fad4
3
+ size: 39554396 bytes
4
+ sha256: b8cc982625b2a18b7dd699d3da005cccf219e5fdb40a6530080547d10fb6a457
@@ -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: 39262108 bytes
4
- sha256: b24c02b2f5d0848c59dba33cf4751107b1799ec8f96c961c3952114e96eb10f7
3
+ size: 39548524 bytes
4
+ sha256: 355d9d4cfaa75f673c7b565296ad4abd101a5a71f9b2c2bb6d4b3b387be8d3fb
@@ -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: 39271824 bytes
4
- sha256: 3bc6176aec3d4bb697ce024b174ddeca6a4b50a297bdd84492a277205e46efed
3
+ size: 39558200 bytes
4
+ sha256: d14cb28779b08d3916683b1c66f7e9f25d29891d9622fab2643724d095d246d7
@@ -117,7 +117,9 @@ module Parsanol
117
117
  # The reporting pass (the re-parse after a failure) must try every
118
118
  # branch so the failure cause tree stays complete; the literal index
119
119
  # only prunes the fast non-reporting pass.
120
- unless context.reporting?
120
+ # Legacy memo answers depend on attempted branches, including a
121
+ # prefix success in a branch that later fails its literal suffix.
122
+ unless context.reporting? || context.parslet_compatible?
121
123
  indexed = candidate_indexes(source)
122
124
  if indexed
123
125
  return try_selected(indexed, options, source, context, consume_all)
@@ -27,17 +27,27 @@ module Parsanol
27
27
  # @param options [Hash] parsing options
28
28
  # @option options [Parsanol::ErrorReporter] :reporter error collector
29
29
  # @option options [Boolean] :prefix allow partial parse (default: false)
30
+ # @option options [Symbol] :compatibility :strict (default), or :parslet
31
+ # for legacy consume-all memo decisions on the Ruby interpreter
30
32
  # @return [Object] the parsed result
31
33
  # @raise [Parsanol::ParseFailed] on parse failure
32
34
  def parse(source, options = {})
33
35
  must_consume_all = !options[:prefix]
36
+ compatibility = options.fetch(:compatibility, :strict)
37
+ unless %i[strict parslet].include?(compatibility)
38
+ raise ArgumentError, "Unknown compatibility policy: #{compatibility}"
39
+ end
40
+
41
+ context_options = compatibility == :strict ? {} : { compatibility: compatibility }
34
42
 
35
43
  # Compiled-VM fast path: String input, full-consumption semantics.
36
44
  # On failure (or unsupported grammar / budget exhaustion) fall
37
45
  # through to the interpreter, which also produces the exact
38
46
  # cause-tree diagnostics. run_for memoizes heavy-backtracking
39
47
  # grammars transparently.
40
- if must_consume_all && source.is_a?(String) &&
48
+ # The VM implements strict memo semantics. The opt-in legacy policy
49
+ # is currently supported by the Ruby interpreter only.
50
+ if compatibility == :strict && must_consume_all && source.is_a?(String) &&
41
51
  (program = VM.program_for(self))
42
52
  begin
43
53
  result = VM.run_for(self, program, source, true)
@@ -62,12 +72,12 @@ module Parsanol
62
72
  input = normalize_input(source)
63
73
 
64
74
  # Initial parse attempt (no error collection)
65
- success, value = run_with_context(input, nil, must_consume_all)
75
+ success, value = run_with_context(input, nil, must_consume_all, **context_options)
66
76
  return finalize_result(value) if success
67
77
 
68
78
  # Reparse with error reporting for diagnostics
69
79
  report_detailed_error(input, must_consume_all, options[:reporter],
70
- value)
80
+ value, **context_options)
71
81
  end
72
82
 
73
83
  # Creates a new parsing context and executes the atom.
@@ -76,10 +86,11 @@ module Parsanol
76
86
  # @param reporter [Object, nil] error reporter
77
87
  # @param consume_all [Boolean] require complete consumption
78
88
  # @return [Array(Boolean, Object)] outcome tuple
79
- def run_with_context(input, reporter, consume_all)
89
+ def run_with_context(input, reporter, consume_all, compatibility: :strict)
80
90
  parser_class = detect_parser_class
81
91
  context = Parsanol::Atoms::Context.new(reporter,
82
- parser_class: parser_class)
92
+ parser_class: parser_class,
93
+ compatibility: compatibility)
83
94
  apply(input, context, consume_all)
84
95
  end
85
96
 
@@ -242,10 +253,12 @@ module Parsanol
242
253
  # consumer's file:line:col degrades to "line 1 char 1" when the
243
254
  # interpreter raises the outermost sequence instead. Explicit
244
255
  # reporters (Tree for full cause trees) are honored unchanged.
245
- def report_detailed_error(input, consume_all, reporter, _initial_error)
256
+ def report_detailed_error(input, consume_all, reporter, _initial_error, compatibility: :strict)
246
257
  input.bytepos = 0
247
- error_reporter = reporter || Parsanol::ErrorReporter::Deepest.new
248
- success, cause = run_with_context(input, error_reporter, consume_all)
258
+ error_reporter = reporter ||
259
+ (compatibility == :parslet ? Parsanol::ErrorReporter::Tree.new : Parsanol::ErrorReporter::Deepest.new)
260
+ context_options = compatibility == :strict ? {} : { compatibility: compatibility }
261
+ success, cause = run_with_context(input, error_reporter, consume_all, **context_options)
249
262
 
250
263
  # Second parse should also fail
251
264
  raise "Invariant violation: parse succeeded during error reporting" if success
@@ -44,11 +44,24 @@ module Parsanol
44
44
  # @param interval_cache: [Boolean] enable GPeg-style interval caching
45
45
  # @param adaptive_cache_threshold: [Integer, nil] minimum input size for caching
46
46
  # @param parser_class: [Class, nil] parser class for threshold selection
47
+ # @param compatibility: [Symbol] :strict or opt-in :parslet. Legacy
48
+ # decisions use eager, parse-lifetime memo entries; mutable capture
49
+ # and dynamic evaluations still retain their cache barriers.
47
50
  #
48
51
  def initialize(error_reporter = Parsanol::ErrorReporter::Tree.new,
49
52
  interval_cache: false,
50
53
  adaptive_cache_threshold: nil,
51
- parser_class: nil)
54
+ parser_class: nil,
55
+ compatibility: :strict)
56
+ unless %i[strict parslet].include?(compatibility)
57
+ raise ArgumentError, "Unknown compatibility policy: #{compatibility}"
58
+ end
59
+
60
+ @parslet_compatibility = compatibility == :parslet
61
+ if @parslet_compatibility && interval_cache
62
+ raise NotImplementedError, "Parslet compatibility does not yet support interval caching"
63
+ end
64
+
52
65
  # Core memoization cache: position -> { atom_id -> [result, advance] }
53
66
  # Auto-vivifying sub-hashes: a probe-phase position can reach the
54
67
  # active-path lookup before any entry exists at that position
@@ -84,11 +97,6 @@ module Parsanol
84
97
  @array_pool = Parsanol::Pools::ArrayPool.new(size: 10_000, preallocate: false)
85
98
  @buffer_pool = Parsanol::Pools::BufferPool.new(pool_size: 100)
86
99
 
87
- # Selective memoization tracking
88
- @hit_stats = Hash.new(0)
89
- @miss_stats = Hash.new(0)
90
- @min_hits_for_cache = 2
91
-
92
100
  # Optional GPeg-style interval caching
93
101
  @use_intervals = interval_cache
94
102
  if @use_intervals
@@ -117,7 +125,7 @@ module Parsanol
117
125
 
118
126
  @adaptive_threshold = threshold
119
127
  @input_len = nil
120
- @caching_active = false
128
+ @caching_active = @parslet_compatibility
121
129
  @backtrack_events = 0
122
130
 
123
131
  # Monotonic counter of cache-unsafe events (dynamic-atom evaluations
@@ -132,6 +140,10 @@ module Parsanol
132
140
  # @return [Integer] monotonic count of cache-unsafe events so far
133
141
  attr_reader :cache_unsafe_events
134
142
 
143
+ def parslet_compatible?
144
+ @parslet_compatibility
145
+ end
146
+
135
147
  # Records that mutable parse state was read or written (dynamic atom
136
148
  # evaluated, capture stored). Called by atoms; results computed across
137
149
  # such events are excluded from memoization.
@@ -152,7 +164,11 @@ module Parsanol
152
164
  #
153
165
  def try_with_cache(atom, src, must_consume_all)
154
166
  # Skip caching for atoms that don't benefit from it
155
- return atom.try(src, self, must_consume_all) unless atom.cached?
167
+ legacy_pure = @parslet_compatibility &&
168
+ (atom.instance_of?(Parsanol::Atoms::Entity) ||
169
+ atom.instance_of?(Parsanol::Atoms::Str) || atom.instance_of?(Parsanol::Atoms::Re))
170
+ cacheable = atom.cached? || legacy_pure
171
+ return atom.try(src, self, must_consume_all) unless cacheable
156
172
 
157
173
  # Use interval-based caching if enabled
158
174
  return try_with_interval(atom, src, must_consume_all) if @use_intervals
@@ -171,7 +187,9 @@ module Parsanol
171
187
  @furthest_pos = pos
172
188
  @evict_counter += 1
173
189
 
174
- if @evict_counter >= @evict_interval
190
+ # Legacy prefix decisions remain observable on later backtracking;
191
+ # retain them for this parse rather than silently re-evaluating.
192
+ if !@parslet_compatibility && @evict_counter >= @evict_interval
175
193
  @evict_counter = 0
176
194
  cutoff = pos - @evict_threshold
177
195
  @memo.delete_if { |p, _| p < cutoff }
@@ -182,26 +200,21 @@ module Parsanol
182
200
  # prefix-success fallback for ordered choice)
183
201
  cached_key = cached_entry_key(@memo[pos], atom, must_consume_all)
184
202
  if cached_key
185
- @hit_stats[cached_key] += 1
186
203
  outcome, delta = @memo[pos][cached_key]
187
204
  src.bytepos = pos + delta
188
205
  return outcome
189
206
  end
190
207
 
191
208
  # Cache miss - execute and store
192
- @miss_stats[key] += 1
193
209
  unsafe_before = @cache_unsafe_events
194
210
  outcome = atom.try(src, self, must_consume_all)
195
211
  delta = src.bytepos - pos
196
212
 
197
- # Never store results spanning cache-unsafe events (dynamic
198
- # blocks, capture writes); keep the beneficial-attempts
199
- # heuristic to avoid one-shot memo pollution.
200
- if storable_outcome?(key, outcome, unsafe_before)
201
- attempts = @hit_stats[key] + @miss_stats[key]
202
- if attempts <= @min_hits_for_cache || @hit_stats[key].positive?
203
- (@memo[pos] ||= {})[key] = [outcome, delta]
204
- end
213
+ # Once backtracking activates memoization, retain every safe answer.
214
+ # An atom-wide attempts limit suppresses its entries at later positions
215
+ # and can leave recursive alternatives with exponential repeated work.
216
+ if @cache_unsafe_events == unsafe_before
217
+ (@memo[pos] ||= {})[key] = [outcome, delta]
205
218
  end
206
219
 
207
220
  outcome
@@ -217,23 +230,21 @@ module Parsanol
217
230
  def try_with_interval(atom, src, must_consume_all)
218
231
  pos = src.bytepos
219
232
  key = scoped_cache_key(atom, must_consume_all)
220
- cached_key, cached = cached_interval_entry(atom, pos,
221
- must_consume_all)
233
+ _cached_key, cached = cached_interval_entry(atom, pos,
234
+ must_consume_all)
222
235
 
223
236
  if cached
224
- @hit_stats[cached_key] += 1
225
237
  outcome, delta = cached
226
238
  src.bytepos = pos + delta
227
239
  return outcome
228
240
  end
229
241
 
230
- @miss_stats[key] += 1
231
242
  unsafe_before = @cache_unsafe_events
232
243
  outcome = atom.try(src, self, must_consume_all)
233
244
  delta = src.bytepos - pos
234
245
  end_pos = pos + delta
235
246
 
236
- if storable_outcome?(key, outcome, unsafe_before)
247
+ if @cache_unsafe_events == unsafe_before
237
248
  @interval_trees[key].insert(pos, end_pos, [outcome, delta])
238
249
  end
239
250
 
@@ -533,6 +544,8 @@ module Parsanol
533
544
  end
534
545
 
535
546
  def scoped_cache_key(atom, must_consume_all)
547
+ return shared_cache_key(atom) if @parslet_compatibility
548
+
536
549
  must_consume_all ? strict_cache_key(atom) : shared_cache_key(atom)
537
550
  end
538
551
 
@@ -554,17 +567,6 @@ module Parsanol
554
567
  !must_consume_all && share_prefix_success_cache?(atom)
555
568
  end
556
569
 
557
- # An outcome may be memoized only when no cache-unsafe event (dynamic
558
- # evaluation, capture write) happened while computing it, and the
559
- # attempts heuristic deems the entry worthwhile.
560
- def storable_outcome?(key, outcome, unsafe_before)
561
- return false unless @cache_unsafe_events == unsafe_before
562
-
563
- outcome.first ||
564
- @hit_stats[key] + @miss_stats[key] <= @min_hits_for_cache ||
565
- @hit_stats[key].positive?
566
- end
567
-
568
570
  def successful_prefix_entry?(entry)
569
571
  entry && entry[0].is_a?(Array) && entry[0].first == true
570
572
  end
@@ -74,6 +74,8 @@ module Parsanol
74
74
  # @param consume_all [Boolean] require full consumption
75
75
  # @return [Array(Boolean, Object)] result
76
76
  def try(source, context, consume_all)
77
+ return try_compatible(source, context, consume_all) if context.parslet_compatible?
78
+
77
79
  # Check for tree memoization support
78
80
  if context.use_tree_memoization?
79
81
  return with_tree_cache(source, context, consume_all)
@@ -119,6 +121,37 @@ module Parsanol
119
121
  first
120
122
  end
121
123
 
124
+ # Legacy repetition checks all children as prefixes. A bounded success
125
+ # returns before the outer consume-all guard; an exhausted optional
126
+ # failure checks consume-all inside #try. Their raw memo answers differ.
127
+ def try_compatible(source, context, consume_all)
128
+ start_pos = source.bytepos
129
+ result = [@result_tag]
130
+ count = 0
131
+ failure = nil
132
+ loop do
133
+ previous = source.bytepos
134
+ success, value = @parslet.apply(source, context, false)
135
+ unless success
136
+ failure = value
137
+ source.bytepos = previous
138
+ break
139
+ end
140
+
141
+ count += 1
142
+ result << value
143
+ return ok(result) if @max && count >= @max
144
+ # Retain the current engine's protection against empty loops.
145
+ break if source.bytepos == previous
146
+ end
147
+ return context.err_at(self, source, min_error, start_pos, [failure]) if count < @min
148
+ if consume_all && source.chars_left.positive?
149
+ return context.err(self, source, @extra_error, [failure])
150
+ end
151
+
152
+ ok(result)
153
+ end
154
+
122
155
  # Optional match (0 or 1)
123
156
  def try_maybe(source, context, _consume_all)
124
157
  success, value = @parslet.apply(source, context, false)
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/native/libparsanol.so
3
- size: 35442920 bytes
4
- sha256: 0a129844ae8acce2330a6b009be2fc9d918a6f46d53f744429ae02b9bf039aea
3
+ size: 35690876 bytes
4
+ sha256: d0d818ff4c6c0f68706b94d0bcee7bb2dca07ce492439b729bf42fbc83e50cc9
@@ -57,9 +57,9 @@ module Parsanol
57
57
  #
58
58
  # @param grammar [Parsanol::Atoms::Base] Ruby grammar or JSON string
59
59
  # @param input [String] Input string to parse
60
- def parse(grammar, input)
60
+ def parse(grammar, input, **)
61
61
  # Delegate to Parsanol::Native.parse for consistency
62
- Parsanol::Native.parse(grammar, input)
62
+ Parsanol::Native.parse(grammar, input, **)
63
63
  end
64
64
 
65
65
  # Parse input with a Ruby grammar, returning the parslet-shaped
@@ -84,8 +84,13 @@ module Parsanol
84
84
  # Resolve a Rust-side handle for the grammar, registering it once.
85
85
  # Per-call cost is the memoized structure-hash lookup plus one Hash
86
86
  # access — no JSON marshal, no re-hash inside Rust.
87
- def grammar_handle(root_atom)
88
- cache_key = grammar_cache_key(root_atom)
87
+ def grammar_handle(root_atom, compatibility: :strict)
88
+ root_atom = root_atom.root if root_atom.is_a?(::Parsanol::Parser)
89
+ legacy = compatibility == :parslet
90
+ # Legacy decisions depend on the exact shared-node graph. The
91
+ # strict structure hash deliberately omits repeated-node targets.
92
+ # Retaining the root in this bounded key also prevents id recycling.
93
+ cache_key = legacy ? [:parslet, root_atom] : grammar_cache_key(root_atom)
89
94
  handle = HANDLE_CACHE[cache_key]
90
95
  if handle
91
96
  # Refresh recency: reinsertion moves the entry to the end,
@@ -94,7 +99,8 @@ module Parsanol
94
99
  return handle
95
100
  end
96
101
 
97
- handle = Native._register_grammar(grammar_json(root_atom))
102
+ json = legacy ? GrammarSerializer.serialize(root_atom) : grammar_json(root_atom)
103
+ handle = Native._register_grammar(json)
98
104
  HANDLE_CACHE[cache_key] = handle
99
105
  trim_caches
100
106
  handle
@@ -58,9 +58,24 @@ module Parsanol
58
58
  # result = Parsanol::Native.parse(str('hello').as(:greeting), "hello\nworld")
59
59
  # result[:greeting].line_and_column # => [1, 1]
60
60
  #
61
- def parse(grammar, input)
61
+ def parse(grammar, input, compatibility: :strict)
62
+ unless %i[strict parslet].include?(compatibility)
63
+ raise ArgumentError, "Unknown compatibility policy: #{compatibility}"
64
+ end
62
65
  raise LoadError, "Native parser not available" unless available?
63
66
 
67
+ if compatibility == :parslet
68
+ unless Parser.extension_loaded? && respond_to?(:_parse_handle_with_compatibility)
69
+ raise NotImplementedError,
70
+ "Native Parslet compatibility requires an extension with _parse_handle_with_compatibility"
71
+ end
72
+ if grammar.is_a?(String)
73
+ raise NotImplementedError, "Native Parslet compatibility requires grammar atoms, not JSON"
74
+ end
75
+
76
+ return parse_compatible_atom_grammar(grammar, input)
77
+ end
78
+
64
79
  # ffi-gem tier (JRuby/TruffleRuby/no-binary MRI): same contract,
65
80
  # batch-decoded results, single reporter-pass error fallback.
66
81
  return Ffi.parse(grammar, input) unless Parser.extension_loaded?
@@ -366,6 +381,35 @@ module Parsanol
366
381
  end
367
382
  end
368
383
 
384
+ # Compatibility failures are authoritative native refusals. Unlike the
385
+ # legacy strict helper, this path never reparses or accepts in Ruby.
386
+ def parse_compatible_atom_grammar(grammar, input)
387
+ handle = Parser.grammar_handle(grammar, compatibility: :parslet)
388
+ raw = begin
389
+ _parse_handle_with_compatibility(handle, input, "parslet")
390
+ rescue ArgumentError
391
+ Parser.invalidate_handle(handle)
392
+ begin
393
+ _parse_handle_with_compatibility(
394
+ Parser.grammar_handle(grammar, compatibility: :parslet), input, "parslet"
395
+ )
396
+ rescue RuntimeError => e
397
+ raise_compatible_parse_error(e, input)
398
+ end
399
+ rescue RuntimeError => e
400
+ raise_compatible_parse_error(e, input)
401
+ end
402
+ BatchDecoder.decode_and_flatten(raw, input, Parsanol::Slice)
403
+ end
404
+
405
+ def raise_compatible_parse_error(error, input)
406
+ message = error.message
407
+ position = message[NATIVE_POS_MARKER, 1].to_i
408
+ cause = Parsanol::Cause.new(message.sub(NATIVE_POS_MARKER, ""),
409
+ Parsanol::Source.new(input), position)
410
+ raise Parsanol::ParseFailed.new(cause.to_s, cause), cause: nil
411
+ end
412
+
369
413
  # Reference-walker path (TODO.sota/8 B1): the plain recursive
370
414
  # relation — no VM, no memo tables, no fused scans, no terminal
371
415
  # fast paths. Deliberately slow; the cross-engine differential
@@ -20,9 +20,24 @@ module Parsanol
20
20
  ENTRY_LINE = /\Aentry\s+(\w+)\s*:\s*(\w+)/
21
21
  USE_LINE = /\Ause\s+(\w+)/
22
22
  GRAMMAR_LINE = /\Agrammar\s+(\w+)\s+version\s+"([^"]+)"/
23
+ # The capture-edition tail: the canonical numeric spelling or the
24
+ # quoted aliases (ruby#361); `mode` stays the deprecated keyword.
25
+ EDITION_TAIL = /
26
+ \s+(?:edition\s+(?:"(?<quoted>[^"]+)"|(?<number>\d+))|mode\s+"(?<mode>[^"]+)")
27
+ /x
28
+ GLUE_LINE = /\Aglue\s+([a-z_][a-z0-9_]*(?:\s+[a-z_][a-z0-9_]*)*)\s*\z/
23
29
 
24
30
  module_function
25
31
 
32
+ # Brace depth for the line-oriented collection: braces inside
33
+ # string literals are grammar data — test pins quote whole
34
+ # language forms, including deliberately malformed ones whose
35
+ # unbalanced braces would push the section depth off (ruby#369).
36
+ def brace_delta(line)
37
+ bare = line.gsub(/"(?:[^"\\]|\\.)*"/, "")
38
+ bare.scan("{").count - bare.scan("}").count
39
+ end
40
+
26
41
  def parse(source)
27
42
  SelfHost.validate(source) unless SelfHost.available? && !SelfHost.valid?(source)
28
43
 
@@ -43,7 +58,17 @@ module Parsanol
43
58
  end
44
59
  next if stripped.empty? || stripped.start_with?("#")
45
60
 
46
- if state[:section]
61
+ if state[:rule_depth]&.positive?
62
+ # A brace-form rule body spans lines (ruby#311): keep
63
+ # appending until its braces balance.
64
+ state[:rule_lines][state[:rule_depth_name]] << line
65
+ state[:rule_depth] += brace_delta(line)
66
+ if state[:rule_depth].zero?
67
+ merge_rule(document, state[:rule_lines][state[:rule_depth_name]])
68
+ state[:rule_depth] = nil
69
+ state[:rule_depth_name] = nil
70
+ end
71
+ elsif state[:section]
47
72
  advance_section(document, state, line, deferred, preprocess_texts)
48
73
  else
49
74
  route_top_level(document, stripped, line, state,
@@ -59,7 +84,7 @@ module Parsanol
59
84
  # Consumes one line inside a deferred/closed-by-brace section.
60
85
  def advance_section(document, state, line, deferred, preprocess_texts)
61
86
  section = state[:section]
62
- state[:depth] += line.scan("{").count - line.scan("}").count
87
+ state[:depth] += brace_delta(line)
63
88
  if state[:depth].positive?
64
89
  preprocess_texts.last << line if section == "preprocess"
65
90
  state[:section_lines] << line
@@ -91,9 +116,19 @@ module Parsanol
91
116
  when USE_LINE
92
117
  document.uses << Regexp.last_match(1)
93
118
  doc_comments = []
119
+ when GLUE_LINE
120
+ document.glue_rules.concat(Regexp.last_match(1).split)
121
+ doc_comments = []
94
122
  when GRAMMAR_LINE
95
123
  document.grammar_name = Regexp.last_match(1)
96
124
  document.version = Regexp.last_match(2)
125
+ tail = stripped.match(EDITION_TAIL)
126
+ document.edition = if tail.nil?
127
+ "v1"
128
+ else
129
+ level = tail[:number] || tail[:quoted] || tail[:mode]
130
+ ["2", "v2"].include?(level) ? "v2" : "v1"
131
+ end
97
132
  doc_comments = []
98
133
  when NAMED_SECTION
99
134
  state[:section] = Regexp.last_match(1)
@@ -128,7 +163,15 @@ module Parsanol
128
163
  document.docs[rule_name] = doc_comments.join("\n") unless doc_comments.empty?
129
164
  doc_comments = []
130
165
  state[:rule_lines][rule_name] = line
131
- merge_rule(document, line)
166
+ depth = brace_delta(line)
167
+ if depth.positive?
168
+ # Brace-form body opens (ruby#311): the rule completes on a
169
+ # later line; the continuation loop appends and merges then.
170
+ state[:rule_depth] = depth
171
+ state[:rule_depth_name] = rule_name
172
+ else
173
+ merge_rule(document, line)
174
+ end
132
175
  else
133
176
  doc_comments = []
134
177
  end
@@ -176,6 +176,9 @@ module Parsanol
176
176
  # Options:
177
177
  # - :reporter - Custom error reporter instance
178
178
  # - :prefix - Allow partial matching (default: false)
179
+ # - :compatibility - :strict (default), or :parslet for legacy memo
180
+ # decisions. Auto mode selects native when available; an older native
181
+ # extension without the compatibility entrypoint raises unsupported.
179
182
  #
180
183
  # @return [Hash, Array, Parsanol::Slice] parsed result with position info
181
184
  # @raise [Parsanol::ParseFailed] when parsing fails
@@ -200,8 +203,22 @@ module Parsanol
200
203
  # parse(input, {mode: :ruby, ...}) is the same call shape a subclass
201
204
  # forwarding options via super produces; :mode must be honored
202
205
  # wherever it appears, or those callers silently get the native path.
203
- mode = opts.delete(:mode) ||
204
- (Parsanol::Native.available? && native_expressible? ? :native : :ruby)
206
+ compatibility = opts.fetch(:compatibility, :strict)
207
+ unless %i[strict parslet].include?(compatibility)
208
+ raise ArgumentError, "Unknown compatibility policy: #{compatibility}"
209
+ end
210
+
211
+ requested_mode = opts.delete(:mode)
212
+ mode = requested_mode ||
213
+ if compatibility == :parslet
214
+ Parsanol::Native.available? ? :native : :ruby
215
+ else
216
+ Parsanol::Native.available? && native_expressible? ? :native : :ruby
217
+ end
218
+ if compatibility == :parslet && mode == :json
219
+ raise NotImplementedError, "Parslet compatibility does not yet support JSON mode"
220
+ end
221
+
205
222
  case mode
206
223
  when :ruby
207
224
  super(input, opts)
@@ -291,6 +308,14 @@ module Parsanol
291
308
  # @return [Object] parse result with Slice objects for position info
292
309
  #
293
310
  def parse_native(input, opts)
311
+ if opts[:compatibility] == :parslet
312
+ if opts[:prefix]
313
+ raise NotImplementedError, "Native Parslet compatibility does not yet support prefix mode"
314
+ end
315
+
316
+ return Parsanol::Native.parse(root, input, compatibility: :parslet)
317
+ end
318
+
294
319
  unless Parsanol::Native.available?
295
320
  Parsanol::Atoms::Base.instance_method(:parse).bind_call(self, input,
296
321
  opts)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.114"
4
+ VERSION = "1.3.116"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -452,6 +452,11 @@ module Parsanol
452
452
  @subs = {} # [obj_id, flag] => pc or :pending
453
453
  @pending = {} # [obj_id, flag] => [[call_idx, body], ...]
454
454
  @backtracking_prone = false
455
+ # Grammar rules form a shared graph. Re-analyzing every reference
456
+ # repeats whole alternative trees during inlining. These caches
457
+ # live only for this compiler and retain the depth limit's meaning.
458
+ @first_byte_tables = {}.compare_by_identity
459
+ @nullability = {}.compare_by_identity
455
460
  end
456
461
 
457
462
  attr_reader :ops, :backtracking_prone
@@ -484,6 +489,13 @@ module Parsanol
484
489
  def first_byte_table(atom, depth = 0)
485
490
  return nil if depth > 10
486
491
 
492
+ cached = (@first_byte_tables[atom] ||= {})
493
+ return cached[depth] if cached.key?(depth)
494
+
495
+ cached[depth] = compute_first_byte_table(atom, depth)&.freeze
496
+ end
497
+
498
+ def compute_first_byte_table(atom, depth)
487
499
  case atom
488
500
  when Parsanol::Atoms::Str
489
501
  b = atom.str.getbyte(0)
@@ -508,7 +520,7 @@ module Parsanol
508
520
  i += 1
509
521
  end
510
522
  else
511
- t = ct
523
+ t = ct.dup
512
524
  end
513
525
  unless nullable?(child, depth + 1)
514
526
  nullable_all = false
@@ -529,7 +541,7 @@ module Parsanol
529
541
  i += 1
530
542
  end
531
543
  else
532
- t = bt
544
+ t = bt.dup
533
545
  end
534
546
  end
535
547
  t
@@ -572,6 +584,13 @@ module Parsanol
572
584
  def nullable?(atom, depth = 0)
573
585
  return false if depth > 10
574
586
 
587
+ cached = (@nullability[atom] ||= {})
588
+ return cached[depth] if cached.key?(depth)
589
+
590
+ cached[depth] = compute_nullable(atom, depth)
591
+ end
592
+
593
+ def compute_nullable(atom, depth)
575
594
  case atom
576
595
  when Parsanol::Atoms::Str then atom.str.bytesize.zero?
577
596
  when Parsanol::Atoms::Re then false
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.114
4
+ version: 1.3.116
5
5
  platform: arm-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-29 00:00:00.000000000 Z
11
+ date: 2026-10-30 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