parsanol 1.3.47 → 1.3.48

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: 84b51a358f1867ed55ebfa7a9c6afbb2d442c23e93e8b44b338fd8479eb38f3c
4
- data.tar.gz: 7d0e702019b1023b1ff41b36099c44b27eb8613d4e02c8b10ee60ac5501a0748
3
+ metadata.gz: '085fe6a0f25c1eadc37dd24b31b4c60a4b96e2224e472e8f4a300ae9b3d25ae5'
4
+ data.tar.gz: e770936723d693d3990cf4d051754d35f0a6d0e0f9bc6a25daef680b46f94766
5
5
  SHA512:
6
- metadata.gz: 6d31070597c02776113dfdafe9749d86f2e2edcaf88700576823d10058a7c244114859f304a1c85856c29c520102dfa64d2553508a95259af1f530995e339bd6
7
- data.tar.gz: d0a35ca10ec03ba72b40b1e7a37043b24065a3246bec795c19f32e3e6e023188b60402c31c775517a1d2a1576b3a392583f21f2ffb371286ee3727d498b096aa
6
+ metadata.gz: d33f1a5069d853dc26153b92b5994310c6b274e1ed7e966a748aa186e08c8a8519db52d264197b347cd94dbdf69bf045bfb42e80d14c1226aa11cce8b643587c
7
+ data.tar.gz: 5a32c726999b8870cd11913dc5ac069238790123771a0c9a11cd9dbf180dd92761b1ccc02a732661458d744281a5fde3ae192bcc4fc501153641a57fdc8a0434
data/HISTORY.txt CHANGED
@@ -5,12 +5,28 @@ Breaking changes:
5
5
  * Simplified API: Single `parse()` method with lazy line/column
6
6
  * Removed deprecated methods: `parse_parslet`, `parse_parslet_with_positions`,
7
7
  `parse_with_transform`, `parse_to_objects`, `parse_raw`
8
+ * Aligned ordered choice with Parslet's prefix-success cache semantics -
9
+ grammars that parse on Parslet 2.0 parse identically; some that fail there
10
+ now raise `ParseFailed` here too
11
+ * Scoped memoization keys by parse mode - strict failures are no longer
12
+ replayed for prefix parses, fixing inputs that failed only above the
13
+ adaptive caching threshold
14
+ * Choice errors enumerate at most 16 alternatives - larger choices report
15
+ "Expected one of N alternatives" (per-branch causes stay in the cause tree)
16
+ * Made `Slice#hash` content-based to match `Slice#eql?` - offset no longer
17
+ affects Hash/Set key identity
18
+ * Froze `Alternative#alternatives` - extend choices with `a | b` instead of
19
+ in-place mutation
8
20
 
9
21
  New features:
10
22
 
11
23
  * Lazy line/column computation in `Slice#line_and_column` - computed only when accessed
12
24
  * `BatchDecoder` module for efficient batch AST processing
13
25
  * Grammar accepts Ruby atoms directly - no JSON serialization step needed
26
+ * Literal-prefix indexing for ordered choices with 16+ branches on the
27
+ pure-Ruby path - failure reporting still tries every branch
28
+ * Parser-class parses memoize at all input sizes by default - thresholds are
29
+ performance-only since cache-unsafe results are never memoized
14
30
 
15
31
  Bug fixes:
16
32
 
@@ -19,6 +35,12 @@ Bug fixes:
19
35
  - Same inner keys → Repetition pattern (keep as array)
20
36
  - Different inner keys → Wrapper pattern (merge inner hashes)
21
37
  * Correct entity extraction for grammars with multiple declarations
38
+ * Fixed memoization of capture-dependent results - `dynamic {}` blocks and
39
+ capture writes are re-evaluated on every attempt
40
+ * Fixed repetition interval/tree memoization to actually replay cached
41
+ results, consistently across consume-all modes
42
+ * Fixed `Buffer#clear!` to clear used slots, plugging a pooled-buffer
43
+ reference leak
22
44
 
23
45
  == Parsanol 1.2.2 (2026-03-07)
24
46
 
@@ -11,6 +11,13 @@
11
11
  module Parsanol
12
12
  module Atoms
13
13
  class Alternative < Parsanol::Atoms::Base
14
+ # Literal-prefix indexing only pays off past this many alternatives
15
+ INDEX_THRESHOLD = 16
16
+ # ... and only when at least this many branches have a static literal prefix
17
+ INDEX_MIN_BRANCHES = 4
18
+ # Above this many alternatives the error message stops enumerating them
19
+ CHOICE_ERROR_DETAIL_LIMIT = 16
20
+
14
21
  # @return [Array<Parsanol::Atoms::Base>] alternative parsers
15
22
  attr_reader :alternatives
16
23
 
@@ -19,8 +26,9 @@ module Parsanol
19
26
  # @param options [Array<Parsanol::Atoms::Base>] alternatives
20
27
  def initialize(*options)
21
28
  super()
22
- @alternatives = options
23
- @choice_error = "Expected one of #{options.inspect}"
29
+ # Frozen so the lazily memoized literal index can never go stale:
30
+ # the index assumes this array is immutable after construction.
31
+ @alternatives = options.freeze
24
32
  end
25
33
 
26
34
  # Adds an alternative with flattening.
@@ -87,7 +95,7 @@ module Parsanol
87
95
  success, value2 = a2.apply(source, context, consume_all)
88
96
  return [success, value2] if success
89
97
 
90
- context.err(self, source, @choice_error, [value1, value2])
98
+ context.err(self, source, choice_error, [value1, value2])
91
99
  end
92
100
 
93
101
  # Three-alternative fast path
@@ -101,11 +109,26 @@ module Parsanol
101
109
  success, value3 = a3.apply(source, context, consume_all)
102
110
  return [success, value3] if success
103
111
 
104
- context.err(self, source, @choice_error, [value1, value2, value3])
112
+ context.err(self, source, choice_error, [value1, value2, value3])
105
113
  end
106
114
 
107
115
  # General case for N alternatives
108
116
  def try_many(options, source, context, consume_all)
117
+ # The reporting pass (the re-parse after a failure) must try every
118
+ # branch so the failure cause tree stays complete; the literal index
119
+ # only prunes the fast non-reporting pass.
120
+ unless context.reporting?
121
+ indexed = candidate_indexes(source)
122
+ if indexed
123
+ return try_selected(indexed, options, source, context, consume_all)
124
+ end
125
+ end
126
+
127
+ try_all(options, source, context, consume_all)
128
+ end
129
+
130
+ # Full scan over every branch (reporting pass, or no usable index)
131
+ def try_all(options, source, context, consume_all)
109
132
  errors = nil
110
133
 
111
134
  options.each do |alt|
@@ -116,7 +139,198 @@ module Parsanol
116
139
  errors << value
117
140
  end
118
141
 
119
- context.err(self, source, @choice_error, errors)
142
+ context.err(self, source, choice_error, errors)
143
+ end
144
+
145
+ # Scan restricted to index-selected branch positions, in original order
146
+ def try_selected(indexes, options, source, context, consume_all)
147
+ errors = nil
148
+
149
+ indexes.each do |idx|
150
+ success, value = options[idx].apply(source, context, consume_all)
151
+ return [success, value] if success
152
+
153
+ errors ||= []
154
+ errors << value
155
+ end
156
+
157
+ context.err(self, source, choice_error, errors)
158
+ end
159
+
160
+ # Branch positions worth trying for the current input, or nil when no
161
+ # index applies and every branch must be scanned
162
+ def candidate_indexes(source)
163
+ index = literal_index
164
+ return nil unless index
165
+
166
+ indexes = index[:always].dup
167
+ prefixes = index[:prefixes]
168
+ max_prefix_bytes = index[:max_prefix_bytes]
169
+ current_prefix = +""
170
+
171
+ valid_prefix_preview(source.peek(max_prefix_bytes)).each_char do |char|
172
+ current_prefix << char
173
+ matches = prefixes[current_prefix]
174
+ indexes.concat(matches) if matches
175
+ end
176
+
177
+ indexes.uniq!
178
+ indexes.sort!
179
+ indexes
180
+ end
181
+
182
+ # Lazily builds and memoizes the literal-prefix index (nil when this
183
+ # choice is too small or has too few literal branches to index)
184
+ def literal_index
185
+ return nil if @alternatives.size < INDEX_THRESHOLD
186
+
187
+ # Benign lazy race: alternatives are frozen at initialization, so
188
+ # concurrent builds produce the same index and the last assignment wins.
189
+ return @literal_index if defined?(@literal_index)
190
+
191
+ @literal_index = build_literal_index
192
+ end
193
+
194
+ # Maps each branch's static literal prefix to its position; branches
195
+ # without one go to the always-tried bucket
196
+ def build_literal_index
197
+ prefixes = {}
198
+ always = []
199
+ indexed_count = 0
200
+ max_prefix_bytes = 0
201
+
202
+ @alternatives.each_with_index do |alt, idx|
203
+ prefix, = static_literal_prefix(alt)
204
+
205
+ if prefix.nil? || prefix.empty?
206
+ always << idx
207
+ next
208
+ end
209
+
210
+ add_to_index(prefixes, prefix, idx)
211
+ indexed_count += 1
212
+ max_prefix_bytes = [max_prefix_bytes, prefix.bytesize].max
213
+ end
214
+
215
+ return nil if indexed_count < INDEX_MIN_BRANCHES
216
+
217
+ {
218
+ prefixes: freeze_prefix_index(prefixes),
219
+ max_prefix_bytes: max_prefix_bytes,
220
+ always: always.freeze,
221
+ }.freeze
222
+ end
223
+
224
+ def add_to_index(prefixes, prefix, idx)
225
+ (prefixes[prefix] ||= []) << idx
226
+ end
227
+
228
+ def freeze_prefix_index(prefixes)
229
+ prefixes.transform_values(&:freeze).freeze
230
+ end
231
+
232
+ # Trims a byte-bounded peek down to valid encoding: the scanner position
233
+ # is always on a character boundary, so only a trailing character can be
234
+ # cut and at most a few iterations are ever needed
235
+ def valid_prefix_preview(preview)
236
+ return preview if preview.valid_encoding?
237
+
238
+ (preview.bytesize - 1).downto(1) do |bytesize|
239
+ trimmed = preview.byteslice(0, bytesize)
240
+ return trimmed if trimmed.valid_encoding?
241
+ end
242
+
243
+ +""
244
+ end
245
+
246
+ # Lazy so building a large choice never pays inspect costs up front;
247
+ # benign lazy race, same as literal_index
248
+ def choice_error
249
+ @choice_error ||= if @alternatives.size <= CHOICE_ERROR_DETAIL_LIMIT
250
+ "Expected one of #{alternatives_inspect}"
251
+ else
252
+ "Expected one of #{@alternatives.size} alternatives"
253
+ end
254
+ end
255
+
256
+ # Inspect that survives branches whose own #inspect raises
257
+ def alternatives_inspect
258
+ @alternatives.inspect
259
+ rescue StandardError
260
+ "[#{@alternatives.map { |atom| atom_inspect(atom) }.join(', ')}]"
261
+ end
262
+
263
+ def atom_inspect(atom)
264
+ atom.inspect
265
+ rescue StandardError
266
+ atom.class.name || atom.class.to_s
267
+ end
268
+
269
+ # Returns [prefix, exact]. prefix is a literal string every successful
270
+ # match of the atom is guaranteed to start with (nil when none can be
271
+ # proven). exact is true only when the atom matches exactly that literal
272
+ # and nothing else, so a parent sequence may keep appending literals
273
+ # from subsequent parts. A partial prefix (exact: false) is still a
274
+ # sound index key on its own, but nothing may be appended after it.
275
+ def static_literal_prefix(atom, seen = {})
276
+ object_id = atom.object_id
277
+ return [nil, false] if seen[object_id]
278
+
279
+ seen[object_id] = true
280
+ marked = true
281
+
282
+ if atom.instance_of?(Parsanol::Atoms::Str)
283
+ [atom.str, true]
284
+ elsif atom.instance_of?(Parsanol::Atoms::Named)
285
+ static_literal_prefix(atom.parslet, seen)
286
+ elsif atom.instance_of?(Parsanol::Atoms::Entity)
287
+ parslet = static_entity_parslet(atom)
288
+ parslet ? static_literal_prefix(parslet, seen) : [nil, false]
289
+ elsif atom.instance_of?(Parsanol::Atoms::Sequence)
290
+ static_sequence_literal_prefix(atom, seen)
291
+ else
292
+ [nil, false]
293
+ end
294
+ ensure
295
+ # Only clear markers set by this frame; an early return for an already
296
+ # seen atom must not remove an ancestor's recursion guard.
297
+ seen.delete(object_id) if marked
298
+ end
299
+
300
+ def static_sequence_literal_prefix(atom, seen)
301
+ prefix = +""
302
+ exact = true
303
+
304
+ atom.parslets.each do |part|
305
+ part_prefix, part_exact = static_literal_prefix(part, seen)
306
+
307
+ if part_prefix.nil?
308
+ exact = false
309
+ break
310
+ end
311
+
312
+ prefix << part_prefix
313
+
314
+ # A partial part may match more input after its own prefix, so
315
+ # literals from later parts are not guaranteed to follow at this
316
+ # offset — appending them would over-claim and mis-prune branches.
317
+ unless part_exact
318
+ exact = false
319
+ break
320
+ end
321
+ end
322
+
323
+ return [nil, false] if prefix.empty?
324
+
325
+ [prefix, exact]
326
+ end
327
+
328
+ # Resolves an Entity's rule block without letting a misbehaving block
329
+ # break index construction (unresolvable entities stay unindexed)
330
+ def static_entity_parslet(atom)
331
+ atom.parslet
332
+ rescue StandardError, NotImplementedError
333
+ nil
120
334
  end
121
335
  end
122
336
  end
@@ -22,7 +22,10 @@ module Parsanol
22
22
  success, result = @inner_atom.apply(source, context, consume_all)
23
23
 
24
24
  if success
25
- # Flatten and store the captured value in context
25
+ # Flatten and store the captured value in context. The write mutates
26
+ # parse state, so enclosing composite results must not be memoized —
27
+ # a replay would skip re-storing the capture.
28
+ context.mark_cache_unsafe!
26
29
  flattened = flatten(result)
27
30
  context.captures[@capture_key] = flattened
28
31
  end
@@ -18,14 +18,21 @@ module Parsanol
18
18
  # Inspired by packrat parsing memoization and incremental parsing techniques.
19
19
  #
20
20
  class Context
21
- # Per-parser cache size thresholds based on profiling different grammar types
22
- # Different grammars benefit from caching at different input sizes
21
+ # Threshold for parser classes without a profiled entry below: recursive
22
+ # parser-class grammars can need memoization even for tiny inputs.
23
+ PARSER_DEFAULT_THRESHOLD = 0
24
+
25
+ # Threshold for plain atom-level contexts (no parser class): memoization
26
+ # only pays off past this input size, so small parses skip the overhead.
27
+ DEFAULT_THRESHOLD = 1000
28
+
29
+ # Per-parser cache size thresholds based on profiling different grammar types.
23
30
  PARSER_CACHE_LIMITS = {
24
31
  "JsonParser" => 10_000, # JSON needs large inputs to benefit
32
+ "JsonParsanolParser" => 10_000,
25
33
  "ErbParser" => 800, # ERB benefits earlier
26
34
  "CalcParser" => 2000, # Calculator has low repetition
27
35
  "SentenceParser" => 5000, # Linear grammar, minimal benefit
28
- :default => 1000,
29
36
  }.freeze
30
37
 
31
38
  # Number of observed backtrack events before packrat caching engages.
@@ -73,6 +80,12 @@ module Parsanol
73
80
  require "parsanol/edit_tracker"
74
81
  @interval_trees = Hash.new { |h, k| h[k] = Parsanol::IntervalTree.new }
75
82
  @edits = Parsanol::EditTracker.new
83
+ # Composite [:tree_memo, key] keys keep repetition tree-memo entries
84
+ # in a namespace separate from try_with_interval entries (raw
85
+ # +/-object_id integers) within the same @interval_trees hash.
86
+ # Memoized because tree-memo lookups sit on hot paths and would
87
+ # otherwise allocate a fresh array per query/store.
88
+ @tree_memo_keys = Hash.new { |h, k| h[k] = [:tree_memo, k].freeze }
76
89
  end
77
90
 
78
91
  # Cut operator support for aggressive eviction
@@ -82,14 +95,35 @@ module Parsanol
82
95
  threshold = adaptive_cache_threshold
83
96
  if threshold.nil? && parser_class
84
97
  name = parser_class.name&.split("::")&.last
85
- threshold = PARSER_CACHE_LIMITS[name] || PARSER_CACHE_LIMITS[:default]
98
+ threshold = PARSER_CACHE_LIMITS.fetch(name, PARSER_DEFAULT_THRESHOLD)
86
99
  end
87
- threshold ||= PARSER_CACHE_LIMITS[:default]
100
+ threshold = DEFAULT_THRESHOLD if threshold.nil?
88
101
 
89
102
  @adaptive_threshold = threshold
90
103
  @input_len = nil
91
104
  @caching_active = false
92
105
  @backtrack_events = 0
106
+
107
+ # Monotonic counter of cache-unsafe events (dynamic-atom evaluations
108
+ # and capture writes). A result whose computation bumped this counter
109
+ # depends on mutable parse state, so replaying it from any cache could
110
+ # change semantics — such results are never stored. This keeps cache
111
+ # thresholds performance-only: dynamic{} blocks are always
112
+ # re-evaluated, as their documentation promises.
113
+ @cache_unsafe_events = 0
114
+ end
115
+
116
+ # @return [Integer] monotonic count of cache-unsafe events so far
117
+ attr_reader :cache_unsafe_events
118
+
119
+ # Records that mutable parse state was read or written (dynamic atom
120
+ # evaluated, capture stored). Called by atoms; results computed across
121
+ # such events are excluded from memoization.
122
+ #
123
+ # @return [Integer] the updated event count
124
+ #
125
+ def mark_cache_unsafe!
126
+ @cache_unsafe_events += 1
93
127
  end
94
128
 
95
129
  # Attempts to parse using memoization. Returns cached result if available,
@@ -114,8 +148,7 @@ module Parsanol
114
148
  return try_uncached_probe(atom, src, must_consume_all) unless @caching_active
115
149
 
116
150
  pos = src.bytepos
117
- key = atom.object_id
118
- entry = @memo[pos]
151
+ key = scoped_cache_key(atom, must_consume_all)
119
152
 
120
153
  # Periodic cache eviction to prevent unbounded growth
121
154
  if pos > @furthest_pos
@@ -129,25 +162,30 @@ module Parsanol
129
162
  end
130
163
  end
131
164
 
132
- # Check for cache hit (avoid default-block Hash allocation per probe)
133
- if entry&.key?(key)
134
- @hit_stats[key] += 1
135
- outcome, delta = entry[key]
165
+ # Check for cache hit (scoped, with the Parslet-compatible
166
+ # prefix-success fallback for ordered choice)
167
+ cached_key = cached_entry_key(@memo[pos], atom, must_consume_all)
168
+ if cached_key
169
+ @hit_stats[cached_key] += 1
170
+ outcome, delta = @memo[pos][cached_key]
136
171
  src.bytepos = pos + delta
137
172
  return outcome
138
173
  end
139
174
 
140
175
  # Cache miss - execute and store
141
176
  @miss_stats[key] += 1
177
+ unsafe_before = @cache_unsafe_events
142
178
  outcome = atom.try(src, self, must_consume_all)
143
179
  delta = src.bytepos - pos
144
180
 
145
- # Only cache if beneficial (heuristic)
146
- attempts = @hit_stats[key] + @miss_stats[key]
147
- if attempts <= @min_hits_for_cache || @hit_stats[key].positive?
148
- (@memo[pos] ||= {})[key] =
149
- [outcome,
150
- delta]
181
+ # Never store results spanning cache-unsafe events (dynamic
182
+ # blocks, capture writes); keep the beneficial-attempts
183
+ # heuristic to avoid one-shot memo pollution.
184
+ if storable_outcome?(key, outcome, unsafe_before)
185
+ attempts = @hit_stats[key] + @miss_stats[key]
186
+ if attempts <= @min_hits_for_cache || @hit_stats[key].positive?
187
+ (@memo[pos] ||= {})[key] = [outcome, delta]
188
+ end
151
189
  end
152
190
 
153
191
  outcome
@@ -162,27 +200,25 @@ module Parsanol
162
200
  #
163
201
  def try_with_interval(atom, src, must_consume_all)
164
202
  pos = src.bytepos
165
- key = atom.object_id
166
-
167
- tree = @interval_trees[key]
168
- cached = tree.query_exact(pos, pos)
203
+ key = scoped_cache_key(atom, must_consume_all)
204
+ cached_key, cached = cached_interval_entry(atom, pos,
205
+ must_consume_all)
169
206
 
170
207
  if cached
171
- @hit_stats[key] += 1
208
+ @hit_stats[cached_key] += 1
172
209
  outcome, delta = cached
173
210
  src.bytepos = pos + delta
174
211
  return outcome
175
212
  end
176
213
 
177
214
  @miss_stats[key] += 1
215
+ unsafe_before = @cache_unsafe_events
178
216
  outcome = atom.try(src, self, must_consume_all)
179
217
  delta = src.bytepos - pos
180
218
  end_pos = pos + delta
181
219
 
182
- attempts = @hit_stats[key] + @miss_stats[key]
183
- if attempts <= @min_hits_for_cache || @hit_stats[key].positive?
184
- tree.insert(pos, end_pos,
185
- [outcome, delta])
220
+ if storable_outcome?(key, outcome, unsafe_before)
221
+ @interval_trees[key].insert(pos, end_pos, [outcome, delta])
186
222
  end
187
223
 
188
224
  outcome
@@ -212,6 +248,14 @@ module Parsanol
212
248
  ERROR_RESULT
213
249
  end
214
250
 
251
+ # Checks if this context is collecting diagnostic errors.
252
+ #
253
+ # @return [Boolean] true when an error reporter is attached
254
+ #
255
+ def reporting?
256
+ !@reporter.nil?
257
+ end
258
+
215
259
  # Reports a successful parse.
216
260
  #
217
261
  # @return [Array(Boolean, Object)] success result tuple
@@ -297,10 +341,8 @@ module Parsanol
297
341
  def query_tree_memo(key, start_pos)
298
342
  return nil unless @use_intervals
299
343
 
300
- tree = @interval_trees[key]
301
- matches = tree.query_overlapping(start_pos, start_pos + 1)
302
- found = matches.find { |interval, _| interval[0] == start_pos }
303
- found ? found[1] : nil
344
+ tree = @interval_trees[tree_memo_cache_key(key)]
345
+ tree.query_starting_at(start_pos).first
304
346
  end
305
347
 
306
348
  # Stores a result in the interval cache.
@@ -313,7 +355,23 @@ module Parsanol
313
355
  def store_tree_memo(key, start_pos, values, end_pos)
314
356
  return unless @use_intervals
315
357
 
316
- @interval_trees[key].insert(start_pos, end_pos, [values, end_pos])
358
+ @interval_trees[tree_memo_cache_key(key)].insert(start_pos, end_pos,
359
+ [values, end_pos])
360
+ end
361
+
362
+ # Removes tree-memo entries overlapping [start_pos, end_pos) for a key.
363
+ # Used when a cached entry is detected stale for the current input so a
364
+ # freshly stored result is not shadowed by the old one.
365
+ #
366
+ # @param key [Integer] cache key
367
+ # @param start_pos [Integer] start position
368
+ # @param end_pos [Integer] end position
369
+ #
370
+ def evict_tree_memo(key, start_pos, end_pos)
371
+ return unless @use_intervals
372
+
373
+ @interval_trees[tree_memo_cache_key(key)].delete_overlapping(start_pos,
374
+ end_pos)
317
375
  end
318
376
 
319
377
  # Marks a cut position for aggressive cache eviction.
@@ -343,6 +401,119 @@ module Parsanol
343
401
  outcome
344
402
  end
345
403
 
404
+ def cached_entry_key(cache, atom, must_consume_all)
405
+ key = scoped_cache_key(atom, must_consume_all)
406
+ return key if cache.key?(key)
407
+
408
+ return nil unless prefix_success_fallback?(atom, must_consume_all)
409
+
410
+ shared_key = shared_cache_key(atom)
411
+ return shared_key if successful_prefix_entry?(cache[shared_key])
412
+
413
+ nil
414
+ end
415
+
416
+ def cached_interval_entry(atom, pos, must_consume_all)
417
+ key = scoped_cache_key(atom, must_consume_all)
418
+ cached = @interval_trees[key].query_exact(pos, pos)
419
+ return [key, cached] if cached
420
+
421
+ cached = cached_interval_success_starting_at(key, pos)
422
+ return [key, cached] if cached
423
+
424
+ if prefix_success_fallback?(atom, must_consume_all)
425
+ shared_key = shared_cache_key(atom)
426
+ shared = cached_interval_success_starting_at(shared_key, pos)
427
+ return [shared_key, shared] if shared
428
+ end
429
+
430
+ [nil, nil]
431
+ end
432
+
433
+ def cached_interval_success_starting_at(key, pos)
434
+ @interval_trees[key].query_starting_at(pos).find do |entry|
435
+ successful_prefix_entry?(entry)
436
+ end
437
+ end
438
+
439
+ def tree_memo_cache_key(key)
440
+ @tree_memo_keys[key]
441
+ end
442
+
443
+ def try_with_prefix_success_cache(atom, src, must_consume_all)
444
+ pos = src.bytepos
445
+ shared_key = shared_cache_key(atom)
446
+
447
+ if prefix_success_fallback?(atom, must_consume_all)
448
+ entry = @memo[pos][shared_key]
449
+ if successful_prefix_entry?(entry)
450
+ outcome, delta = entry
451
+ src.bytepos = pos + delta
452
+ return outcome
453
+ end
454
+ end
455
+
456
+ unsafe_before = @cache_unsafe_events
457
+ outcome = atom.try(src, self, must_consume_all)
458
+
459
+ if @cache_unsafe_events == unsafe_before &&
460
+ !must_consume_all && outcome.first &&
461
+ share_prefix_success_cache?(atom)
462
+ delta = src.bytepos - pos
463
+ # This path intentionally keeps only shared prefix successes while
464
+ # full memoization is inactive. That preserves Parslet ordered-choice
465
+ # semantics without turning small parses into fully memoized parses.
466
+ @memo[pos][shared_key] = [outcome, delta]
467
+ end
468
+
469
+ outcome
470
+ end
471
+
472
+ def scoped_cache_key(atom, must_consume_all)
473
+ must_consume_all ? strict_cache_key(atom) : shared_cache_key(atom)
474
+ end
475
+
476
+ def strict_cache_key(atom)
477
+ -atom.object_id
478
+ end
479
+
480
+ def shared_cache_key(atom)
481
+ atom.object_id
482
+ end
483
+
484
+ # True when a non-strict attempt may fall back to a shared
485
+ # prefix-success entry for this atom. A strict (consume-all)
486
+ # attempt must never replay a shared entry: parslet semantics
487
+ # re-execute the ordered choice, and only a fresh parse can take
488
+ # a longer alternative when the prefix replay starves the
489
+ # consume-all recheck (parsanol-ruby#22).
490
+ def prefix_success_fallback?(atom, must_consume_all)
491
+ !must_consume_all && share_prefix_success_cache?(atom)
492
+ end
493
+
494
+ # An outcome may be memoized only when no cache-unsafe event (dynamic
495
+ # evaluation, capture write) happened while computing it, and the
496
+ # attempts heuristic deems the entry worthwhile.
497
+ def storable_outcome?(key, outcome, unsafe_before)
498
+ return false unless @cache_unsafe_events == unsafe_before
499
+
500
+ outcome.first ||
501
+ @hit_stats[key] + @miss_stats[key] <= @min_hits_for_cache ||
502
+ @hit_stats[key].positive?
503
+ end
504
+
505
+ def successful_prefix_entry?(entry)
506
+ entry && entry[0].is_a?(Array) && entry[0].first == true
507
+ end
508
+
509
+ # Entity, Named, and Ignored delegate to wrapped atoms before this cache
510
+ # lookup, so only cache-participating built-ins belong in this whitelist.
511
+ def share_prefix_success_cache?(atom)
512
+ atom.instance_of?(Parsanol::Atoms::Alternative) ||
513
+ atom.instance_of?(Parsanol::Atoms::Repetition) ||
514
+ atom.instance_of?(Parsanol::Atoms::Sequence)
515
+ end
516
+
346
517
  # Lookup cached result (uses object_id for speed)
347
518
  def lookup(atom, pos)
348
519
  @memo[pos][atom.object_id]
@@ -23,6 +23,11 @@ module Parsanol
23
23
  end
24
24
 
25
25
  def try(source, context, consume_all)
26
+ # The block reads mutable parse state (captures), so no enclosing
27
+ # composite result may be memoized — replaying it would skip this
28
+ # re-evaluation.
29
+ context.mark_cache_unsafe!
30
+
26
31
  # Phase 55: Cache @block ivar to reduce lookup overhead
27
32
  block = @block
28
33
  result = block.call(source, context)
@@ -180,13 +180,15 @@ module Parsanol
180
180
  # Pre-size for the tag + a modest run; Array growth is amortized.
181
181
  result = Array.new([@max || 8, 8].min + 1)
182
182
  result[0] = @result_tag
183
- last_error = nil
183
+ last_failure = nil
184
184
 
185
185
  loop do
186
186
  success, value = @parslet.apply(source, context, false)
187
- last_error = value
188
187
 
189
- break unless success
188
+ unless success
189
+ last_failure = value
190
+ break
191
+ end
190
192
 
191
193
  occurrence += 1
192
194
  result[occurrence] = value
@@ -198,12 +200,13 @@ module Parsanol
198
200
  if occurrence < @min
199
201
  source.bytepos = start_pos
200
202
  return context.err_at(self, source, @min_error, start_pos,
201
- [last_error])
203
+ failure_children(last_failure))
202
204
  end
203
205
 
204
206
  # Check complete consumption
205
207
  if consume_all && source.chars_left.positive?
206
- return context.err(self, source, @extra_error, [last_error])
208
+ return context.err(self, source, @extra_error,
209
+ failure_children(last_failure))
207
210
  end
208
211
 
209
212
  # Trim to the actual filled length (tag + occurrence matches).
@@ -217,11 +220,27 @@ module Parsanol
217
220
  cache_key = object_id
218
221
 
219
222
  # Check cache
223
+ replayed_strict_miss = false
220
224
  cached = context.query_tree_memo(cache_key, start_pos)
221
225
  if cached
222
226
  values, end_pos = cached
223
227
  source.bytepos = end_pos
224
- return ok([@result_tag] + values)
228
+ if source.bytepos == end_pos
229
+ unless consume_all && source.chars_left.positive?
230
+ return ok([@result_tag] + values)
231
+ end
232
+
233
+ # The cached prefix cannot satisfy consume-all. Re-parse instead
234
+ # of erroring from the replay so diagnostics match the uncached
235
+ # path; remember not to re-store the entry we already have.
236
+ replayed_strict_miss = true
237
+ else
238
+ # Entry points past the end of the current input — stale (context
239
+ # reused across inputs). Evict it so the re-parsed result can be
240
+ # stored and found by later parses instead of being shadowed.
241
+ context.evict_tree_memo(cache_key, start_pos, end_pos)
242
+ end
243
+ source.bytepos = start_pos
225
244
  end
226
245
 
227
246
  # Parse and cache
@@ -231,13 +250,16 @@ module Parsanol
231
250
 
232
251
  positions = context.acquire_array
233
252
  positions << start_pos
234
- last_error = nil
253
+ unsafe_before = context.cache_unsafe_events
254
+ last_failure = nil
235
255
 
236
256
  loop do
237
257
  success, value = @parslet.apply(source, context, false)
238
- last_error = value
239
258
 
240
- break unless success
259
+ unless success
260
+ last_failure = value
261
+ break
262
+ end
241
263
 
242
264
  occurrence += 1
243
265
  result[occurrence] = value
@@ -246,8 +268,21 @@ module Parsanol
246
268
  break if @max && occurrence >= @max
247
269
  end
248
270
 
249
- # Cache successful prefix
250
- if occurrence.positive?
271
+ # Check minimum
272
+ if occurrence < @min
273
+ context.release_array(positions)
274
+ source.bytepos = start_pos
275
+ return context.err_at(self, source, @min_error, start_pos,
276
+ failure_children(last_failure))
277
+ end
278
+
279
+ # Cache only after the repetition itself has succeeded. A partial prefix
280
+ # below the minimum bound is not a valid repetition result to replay,
281
+ # results computed across cache-unsafe events (dynamic/capture) must be
282
+ # re-evaluated each time, and a strict-miss replay already has its
283
+ # entry stored.
284
+ if occurrence.positive? && !replayed_strict_miss &&
285
+ context.cache_unsafe_events == unsafe_before
251
286
  end_pos = positions[occurrence]
252
287
  context.store_tree_memo(cache_key, start_pos,
253
288
  result[1, occurrence], end_pos)
@@ -258,17 +293,25 @@ module Parsanol
258
293
  if occurrence < @min
259
294
  source.bytepos = start_pos
260
295
  return context.err_at(self, source, @min_error, start_pos,
261
- [last_error])
296
+ failure_children(last_failure))
262
297
  end
263
298
 
264
299
  # Check consumption
265
300
  if consume_all && source.chars_left.positive?
266
- return context.err(self, source, @extra_error, [last_error])
301
+ return context.err(self, source, @extra_error,
302
+ failure_children(last_failure))
267
303
  end
268
304
 
269
305
  result.pop(result.size - occurrence - 1) if result.size > occurrence + 1
270
306
  ok(result)
271
307
  end
308
+
309
+ # A max-bounded loop can end with no inner failure; only attach a child
310
+ # cause when one exists (a success Slice is not a Cause and would break
311
+ # ascii_tree rendering).
312
+ def failure_children(last_failure)
313
+ last_failure && [last_failure]
314
+ end
272
315
  end
273
316
  end
274
317
  end
@@ -95,7 +95,7 @@ module Parsanol
95
95
  #
96
96
  def clear!
97
97
  # Clear references for GC (keep capacity)
98
- @size.upto(@capacity - 1) { |i| @storage[i] = nil }
98
+ @storage.fill(nil, 0, @size)
99
99
  @size = 0
100
100
  self
101
101
  end
@@ -9,6 +9,7 @@
9
9
  # Performance characteristics:
10
10
  # - Insert: O(log n)
11
11
  # - Query: O(log n + k) where k is number of overlapping intervals
12
+ # - Query by start position: O(log n + k)
12
13
  # - Delete overlapping: O(log n + k)
13
14
  #
14
15
  module Parsanol
@@ -72,6 +73,27 @@ module Parsanol
72
73
  find_exact(@root, low, high)
73
74
  end
74
75
 
76
+ # Query for intervals that start at a specific position
77
+ # @param low [Integer] Start position to match
78
+ # @return [Array<Object>] Data for intervals whose start equals low
79
+ def query_starting_at(low)
80
+ results = []
81
+ node = @root
82
+
83
+ while node
84
+ if low < node.low
85
+ node = node.left
86
+ elsif low > node.low
87
+ node = node.right
88
+ else
89
+ results << node.data
90
+ node = node.right
91
+ end
92
+ end
93
+
94
+ results
95
+ end
96
+
75
97
  # Delete all intervals that overlap with [low, high)
76
98
  # Returns array of deleted data
77
99
  # @param low [Integer] Start position (inclusive)
@@ -140,16 +162,18 @@ module Parsanol
140
162
 
141
163
  # Find exact interval match
142
164
  def find_exact(node, low, high)
143
- return nil if node.nil?
144
-
145
- return node.data if node.low == low && node.high == high
146
-
147
- # Search in appropriate subtree
148
- if low < node.low
149
- find_exact(node.left, low, high)
150
- else
151
- find_exact(node.right, low, high)
165
+ while node
166
+ return node.data if node.low == low && node.high == high
167
+
168
+ # Search in appropriate subtree
169
+ node = if low < node.low
170
+ node.left
171
+ else
172
+ node.right
173
+ end
152
174
  end
175
+
176
+ nil
153
177
  end
154
178
 
155
179
  # Delete overlapping intervals recursively
@@ -53,7 +53,7 @@ module Parsanol
53
53
  end
54
54
 
55
55
  def hash
56
- [content, offset].hash
56
+ [Parsanol::Slice, content].hash
57
57
  end
58
58
 
59
59
  # Delegated methods
@@ -10,11 +10,20 @@ module Parsanol
10
10
  # Caches line ending positions for quick line/column resolution.
11
11
  # Uses binary search for efficient position lookup.
12
12
  class LineCache
13
- def initialize
13
+ # Creates a line cache, optionally bound to a one-shot buffer that is
14
+ # scanned lazily on the first line_and_column call.
15
+ #
16
+ # @param buffer [String, nil] input to scan lazily; nil for callers that
17
+ # feed windows incrementally via scan_for_line_endings
18
+ # @param start_offset [Integer] byte offset of the buffer's first byte
19
+ def initialize(buffer = nil, start_offset = 0)
14
20
  # Array of byte offsets where each line ends
15
21
  @breaks = []
16
22
  @breaks.extend(IntervalLookup)
17
23
  @max_scanned = nil
24
+ @buffer = buffer
25
+ @start_offset = start_offset
26
+ @fully_scanned = false
18
27
  end
19
28
 
20
29
  # Converts a byte offset to [line_number, column_number].
@@ -23,6 +32,8 @@ module Parsanol
23
32
  # @param position [Integer, #bytepos] the byte offset to convert
24
33
  # @return [Array<Integer, Integer>] [line, column] tuple
25
34
  def line_and_column(position)
35
+ scan_buffer_once
36
+
26
37
  position = position.bytepos if position.respond_to?(:bytepos)
27
38
 
28
39
  line_idx = @breaks.lower_bound_index(position)
@@ -39,7 +50,10 @@ module Parsanol
39
50
  end
40
51
 
41
52
  # Scans a string buffer for line endings and caches their positions.
42
- # Avoids re-scanning already processed regions.
53
+ # Avoids re-scanning already processed regions. Incremental callers must
54
+ # feed windows of one consistent input in monotonically advancing,
55
+ # contiguous-or-overlapping order; non-contiguous or out-of-order scans
56
+ # are skipped where already covered and can miss line endings in gaps.
43
57
  #
44
58
  # @param start_offset [Integer] the byte offset where buffer starts
45
59
  # @param buffer [String] the string content to scan
@@ -47,16 +61,34 @@ module Parsanol
47
61
  return unless buffer
48
62
 
49
63
  scanner = StringScanner.new(buffer)
50
- return unless scanner.exist?(/\n/)
51
-
52
- # Skip already-scanned content
53
- scanner.pos = @max_scanned - start_offset if @max_scanned && start_offset < @max_scanned
64
+ if scanner.exist?(/\n/)
65
+ # Skip already-scanned content. @max_scanned can extend past this
66
+ # buffer's window (it advances to the end of every scanned buffer),
67
+ # so clamp to the window to keep the scanner position valid.
68
+ if @max_scanned && start_offset < @max_scanned
69
+ scanner.pos = [@max_scanned - start_offset, buffer.bytesize].min
70
+ end
54
71
 
55
- # Record all newline positions
56
- while scanner.skip_until(/\n/)
57
- @max_scanned = start_offset + scanner.pos
58
- @breaks << @max_scanned
72
+ # Record all newline positions
73
+ while scanner.skip_until(/\n/)
74
+ @max_scanned = start_offset + scanner.pos
75
+ @breaks << @max_scanned
76
+ end
59
77
  end
78
+
79
+ @max_scanned = [@max_scanned || start_offset, start_offset + buffer.bytesize].max
80
+ end
81
+
82
+ private
83
+
84
+ # Scans the one-shot constructor buffer on first use, then releases it
85
+ # so retained Slices do not pin the entire input string.
86
+ def scan_buffer_once
87
+ return if @fully_scanned || !@buffer
88
+
89
+ scan_for_line_endings(@start_offset, @buffer)
90
+ @fully_scanned = true
91
+ @buffer = nil
60
92
  end
61
93
  end
62
94
 
@@ -38,18 +38,18 @@ module Parsanol
38
38
  "Source requires a string-like object (responds to to_str)"
39
39
  end
40
40
 
41
- # Core scanner for input traversal
42
- @scanner = StringScanner.new(input)
43
41
  @raw_string = input.to_str
44
42
 
43
+ # Core scanner for input traversal
44
+ @scanner = StringScanner.new(@raw_string)
45
+
45
46
  # Regex cache: maps count n to /(.|$){n}/m pattern
46
47
  @regex_cache = Hash.new do |h, count|
47
48
  h[count] = Regexp.new("(.|$){#{count}}", Regexp::MULTILINE)
48
49
  end
49
50
 
50
51
  # Line ending cache for position-to-line/column mapping
51
- @line_data = LineCache.new
52
- @line_data.scan_for_line_endings(0, input)
52
+ @line_data = LineCache.new(@raw_string)
53
53
 
54
54
  # Object pools for memory efficiency
55
55
  # SlicePool: reduces Slice allocations during matching
@@ -160,6 +160,23 @@ module Parsanol
160
160
  @scanner.rest_size
161
161
  end
162
162
 
163
+ # Returns the unconsumed input from the current position without advancing.
164
+ #
165
+ # @return [String] remaining input
166
+ #
167
+ def remaining
168
+ @scanner.rest
169
+ end
170
+
171
+ # Returns up to byte_count bytes from the current position without advancing.
172
+ #
173
+ # @param byte_count [Integer] maximum bytes to read
174
+ # @return [String] bounded input preview
175
+ #
176
+ def peek(byte_count)
177
+ @scanner.peek(byte_count)
178
+ end
179
+
163
180
  # Counts characters from current position until a target string.
164
181
  # Returns chars_left if target is not found.
165
182
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.47"
4
+ VERSION = "1.3.48"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -103,7 +103,17 @@ module Parsanol
103
103
  return nil if cached == :fallback
104
104
  return cached if key?(cache, key)
105
105
 
106
- cache[key] = compile(atom)
106
+ # A grammar the compiler cannot compile — e.g. one containing a
107
+ # lazily resolved Entity that a select-first literal index would
108
+ # never select — falls back to the interpreter, the source of
109
+ # truth. The interpreter resolves such entities lazily, so an
110
+ # unselected branch simply never raises.
111
+ begin
112
+ cache[key] = compile(atom)
113
+ rescue NotImplementedError
114
+ cache[key] = :fallback
115
+ nil
116
+ end
107
117
  end
108
118
 
109
119
  # Marks a grammar as VM-incompatible after a runtime failure.
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.47
4
+ version: 1.3.48
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.