parsanol 1.3.115 → 1.3.116

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: 116a1ab6a5ed95d4b3998c1d1d0b4860ed7dabafb5464c8aa8ff7d8602d9c221
4
- data.tar.gz: 0f4799930aa443744b0ed3367fc139ab18eb565aca24a640a642d37b80596736
3
+ metadata.gz: b6be4ab27d5d8c86b4f214b75cefb0bb16a3de4eb9e55402d93ca807fae280aa
4
+ data.tar.gz: cf56a4e2921063811e55a4016342cce471acf24138825ebe24673f5563cf70ea
5
5
  SHA512:
6
- metadata.gz: df5ecd79facb4f09fcbbd5328ce3a4d2713fb230322a1f6ff4fccfd68fc6b12cbb6fa5db573df86035e3ce85c1626bed58b75c494a38701305456d35c34f6502
7
- data.tar.gz: c1f70bd90dd3ba48126dbb010d9ba89013b39cab654284224afe5031d7713caae84cc3c26b6b454f508b6fa7083504a52bc49dc1c18651a9c381042a94588b71
6
+ metadata.gz: 13957312679b48cf55f8455e8a6311c785915dd95e9c940d974f487bc814ec07de54fe30f860912bfc9b42b7b51645110b95e6d52ccba266951d3d9523d864f7
7
+ data.tar.gz: 852e7731ce29b534bd383010655cb6380c9bac8840c69204904d1e17de089e5db578c13ad3c65707aa8d686ebd5ffc50ea526035256a474c8cdf7ea555cb1838
data/Cargo.lock CHANGED
@@ -307,9 +307,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
307
307
 
308
308
  [[package]]
309
309
  name = "parsanol"
310
- version = "0.19.10"
310
+ version = "0.19.12"
311
311
  source = "registry+https://github.com/rust-lang/crates.io-index"
312
- checksum = "19a06284aa6f46b116ad287275b3febfbd5f36b0f85b130f492d8244afea8c13"
312
+ checksum = "2d8c6e0a21c9ee8cc6e661e4109a70d1c8f4f62c963eb12230b8d7035ebb63ee"
313
313
  dependencies = [
314
314
  "ahash",
315
315
  "getrandom 0.3.4",
@@ -326,9 +326,9 @@ dependencies = [
326
326
 
327
327
  [[package]]
328
328
  name = "parsanol-derive"
329
- version = "0.19.10"
329
+ version = "0.19.12"
330
330
  source = "registry+https://github.com/rust-lang/crates.io-index"
331
- checksum = "dd454669ba1439d5c4f1bb3cd71861cda751586a3ddf2521383f8fbbb4b19049"
331
+ checksum = "2662678735acff3124dc2514d4792cc4a82cfaff50ed3b0a88c2a5662ecb9818"
332
332
  dependencies = [
333
333
  "proc-macro2",
334
334
  "quote",
@@ -27,12 +27,8 @@ rb-sys = { version = "0.9.124", features = ["global-allocator"] }
27
27
  # magnus for Ruby bindings (from crates.io, patched locally for Ruby 4.0 support)
28
28
  magnus = { version = "0.9" }
29
29
 
30
- # parsanol parser library (crates.io; 0.12 carries the rs#174 native
31
- # backtracking fix: dynamic-dependent memoization + outcome cache)
32
- # parsanol parser library (crates.io; 0.17.0 carries the LookSpec regex
33
- # lookbehind (rs#163 gap 1) and the widened opaque writeback: the
34
- # lazy-continuation fix, parsanol-ruby#160)
35
- parsanol = { version = "0.19.1", features = ["ruby"] }
30
+ # Native legacy compatibility and compact recursive parser results.
31
+ parsanol = { version = "0.19.12", features = ["ruby"] }
36
32
 
37
33
  # Logging
38
34
  log = "0.4"
@@ -30,6 +30,15 @@ create_rust_makefile("parsanol/parsanol_native") do |r|
30
30
  # Create debug builds in dev, release in production
31
31
  r.profile = ENV.fetch("RB_SYS_CARGO_PROFILE", :dev).to_sym
32
32
 
33
+ # Persist RubyInstaller's headers into Make's later Cargo subprocess.
34
+ if RUBY_PLATFORM.include?("mingw") && ENV.key?("RI_DEVKIT") && ENV.key?("MSYSTEM_PREFIX")
35
+ sysroot = ENV.fetch("RI_DEVKIT").tr("\\", "/") + ENV.fetch("MSYSTEM_PREFIX")
36
+ r.env["BINDGEN_EXTRA_CLANG_ARGS"] = ENV.fetch("BINDGEN_EXTRA_CLANG_ARGS") do
37
+ "--target=x86_64-pc-windows-gnu --sysroot=#{sysroot} " \
38
+ "-D__AVX512VLFP16INTRIN_H -D__AVX512FP16INTRIN_H"
39
+ end
40
+ end
41
+
33
42
  # cargo rustc builds dependency cdylibs verbatim, and parsanol ships
34
43
  # a cdylib crate-type (the ffi tier). On macOS every dylib in the
35
44
  # build resolves ruby symbols from the host process at load time, so
@@ -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)
@@ -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
@@ -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.115"
4
+ VERSION = "1.3.116"
5
5
  end
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.115
4
+ version: 1.3.116
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.