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 +4 -4
- data/HISTORY.txt +22 -0
- data/lib/parsanol/atoms/alternative.rb +219 -5
- data/lib/parsanol/atoms/capture.rb +4 -1
- data/lib/parsanol/atoms/context.rb +202 -31
- data/lib/parsanol/atoms/dynamic.rb +5 -0
- data/lib/parsanol/atoms/repetition.rb +56 -13
- data/lib/parsanol/buffer.rb +1 -1
- data/lib/parsanol/interval_tree.rb +33 -9
- data/lib/parsanol/slice.rb +1 -1
- data/lib/parsanol/source/line_cache.rb +42 -10
- data/lib/parsanol/source.rb +21 -4
- data/lib/parsanol/version.rb +1 -1
- data/lib/parsanol/vm.rb +11 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '085fe6a0f25c1eadc37dd24b31b4c60a4b96e2224e472e8f4a300ae9b3d25ae5'
|
|
4
|
+
data.tar.gz: e770936723d693d3990cf4d051754d35f0a6d0e0f9bc6a25daef680b46f94766
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
23
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
22
|
-
#
|
|
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
|
|
98
|
+
threshold = PARSER_CACHE_LIMITS.fetch(name, PARSER_DEFAULT_THRESHOLD)
|
|
86
99
|
end
|
|
87
|
-
threshold
|
|
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
|
|
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 (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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[
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
250
|
-
if occurrence
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/parsanol/buffer.rb
CHANGED
|
@@ -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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
data/lib/parsanol/slice.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
data/lib/parsanol/source.rb
CHANGED
|
@@ -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
|
#
|
data/lib/parsanol/version.rb
CHANGED
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
|
-
|
|
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.
|