parsanol 1.3.67-arm-linux → 1.3.69-arm-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/parsanol/atoms/context.rb +23 -4
- data/lib/parsanol/atoms/sequence.rb +5 -1
- data/lib/parsanol/atoms/trivia.rb +21 -0
- data/lib/parsanol/atoms.rb +1 -0
- data/lib/parsanol/cause.rb +38 -2
- data/lib/parsanol/native/ffi.rb +28 -3
- data/lib/parsanol/native/parser.rb +55 -5
- data/lib/parsanol/parg/compiler.rb +37 -15
- data/lib/parsanol/parg/document.rb +6 -1
- data/lib/parsanol/parg/parser.rb +7 -1
- data/lib/parsanol/version.rb +1 -1
- data/lib/parsanol/vm.rb +88 -20
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02aeebc53cc2ea8dc7e796d60641108957224ddc0cbdf7be96fcd700d2721852
|
|
4
|
+
data.tar.gz: ae85787c63347aeee8f55ee3e775a004b8412115c6b1ff59d4cc293672d39bf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee5cd016cfa166ec4e88736c04d0222c09ff0eabe11c8a354b5b1453bb7177aece7baf741cede85bc68744a83f370119dd031ba5cffd67e1ec84c29ce95aefc1
|
|
7
|
+
data.tar.gz: 1e1a75fb79bbd87e39f9c63c0ffde10666193732590c41ad03112c779ff484994b640bdf3152e5aaa3a5699eef25c333178a1973eb8d251e4a7dcdaed2466aa2
|
|
@@ -63,6 +63,10 @@ module Parsanol
|
|
|
63
63
|
|
|
64
64
|
# Cache eviction state
|
|
65
65
|
@furthest_pos = 0
|
|
66
|
+
|
|
67
|
+
# Injected-trivia depth: while positive, reported causes are
|
|
68
|
+
# marked trivia and never surface in diagnostics.
|
|
69
|
+
@trivia_depth = 0
|
|
66
70
|
@evict_threshold = 200
|
|
67
71
|
@evict_counter = 0
|
|
68
72
|
@evict_interval = 100
|
|
@@ -230,15 +234,17 @@ module Parsanol
|
|
|
230
234
|
# Pre-allocated result constants
|
|
231
235
|
SUCCESS_RESULT = [true, nil].freeze
|
|
232
236
|
ERROR_RESULT = [false, nil].freeze
|
|
237
|
+
TRIVIA_ERROR = [false, Cause::TriviaCause.instance].freeze
|
|
233
238
|
|
|
234
239
|
# Reports an error at a specific position.
|
|
235
240
|
#
|
|
236
241
|
# @return [Array(Boolean, Object)] error result tuple
|
|
237
242
|
#
|
|
238
243
|
def err_at(*)
|
|
239
|
-
return
|
|
244
|
+
return TRIVIA_ERROR if @trivia_depth.positive?
|
|
245
|
+
return ERROR_RESULT unless @reporter
|
|
240
246
|
|
|
241
|
-
|
|
247
|
+
[false, @reporter.err_at(*)]
|
|
242
248
|
end
|
|
243
249
|
|
|
244
250
|
# Reports an error at the current position.
|
|
@@ -246,9 +252,22 @@ module Parsanol
|
|
|
246
252
|
# @return [Array(Boolean, Object)] error result tuple
|
|
247
253
|
#
|
|
248
254
|
def err(*)
|
|
249
|
-
return
|
|
255
|
+
return TRIVIA_ERROR if @trivia_depth.positive?
|
|
256
|
+
return ERROR_RESULT unless @reporter
|
|
257
|
+
|
|
258
|
+
[false, @reporter.err(*)]
|
|
259
|
+
end
|
|
250
260
|
|
|
251
|
-
|
|
261
|
+
# Brackets a parse inside injected trivia: causes reported while
|
|
262
|
+
# the depth is positive carry the trivia flag and never surface
|
|
263
|
+
# in rendered trees or deepest-failure positions.
|
|
264
|
+
def with_trivia
|
|
265
|
+
@trivia_depth += 1
|
|
266
|
+
begin
|
|
267
|
+
yield
|
|
268
|
+
ensure
|
|
269
|
+
@trivia_depth -= 1
|
|
270
|
+
end
|
|
252
271
|
end
|
|
253
272
|
|
|
254
273
|
# Checks if this context is collecting diagnostic errors.
|
|
@@ -77,7 +77,11 @@ module Parsanol
|
|
|
77
77
|
# @param prec [Integer] precedence
|
|
78
78
|
# @return [String]
|
|
79
79
|
def to_s_inner(prec)
|
|
80
|
-
|
|
80
|
+
# Injected trivia is invisible in diagnostics: a failed
|
|
81
|
+
# sequence describes what the grammar asks for, not the skip
|
|
82
|
+
# machinery interleaved around it.
|
|
83
|
+
@parslets.grep_v(Parsanol::Atoms::Trivia)
|
|
84
|
+
.map { |p| p.to_s(prec) }.join(" ")
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
# FIRST set is first element's FIRST set (with epsilon propagation).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Parsanol
|
|
4
|
+
module Atoms
|
|
5
|
+
# The injected skip wrapper: Ignored semantics (matched bytes
|
|
6
|
+
# contribute nothing to values or spans) plus diagnostics
|
|
7
|
+
# transparency — failures inside trivia are marked and never
|
|
8
|
+
# surface in rendered trees or deepest-failure positions.
|
|
9
|
+
class Trivia < Ignored
|
|
10
|
+
def apply(source, context, consume_all)
|
|
11
|
+
context.with_trivia do
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s_inner(prec)
|
|
17
|
+
"trivia(#{@wrapped_atom.to_s(prec)})"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/parsanol/atoms.rb
CHANGED
data/lib/parsanol/cause.rb
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
# cause.children # => []
|
|
17
17
|
# cause.to_s # => "Expected at least one"
|
|
18
18
|
#
|
|
19
|
+
require "singleton"
|
|
20
|
+
|
|
19
21
|
module Parsanol
|
|
20
22
|
class Cause
|
|
21
23
|
# @return [Array<String>] Error message parts
|
|
@@ -33,6 +35,15 @@ module Parsanol
|
|
|
33
35
|
# @return [Array<Cause>] Child causes
|
|
34
36
|
attr_reader :children
|
|
35
37
|
|
|
38
|
+
# True when this failure happened inside injected skip trivia:
|
|
39
|
+
# trivia positions never surface in diagnostics (rendered trees
|
|
40
|
+
# prune trivia causes; deepest-failure tracking ignores them).
|
|
41
|
+
attr_accessor :trivia
|
|
42
|
+
|
|
43
|
+
def trivia?
|
|
44
|
+
@trivia == true
|
|
45
|
+
end
|
|
46
|
+
|
|
36
47
|
# Creates a new cause for parse failure
|
|
37
48
|
#
|
|
38
49
|
# @param message [String, Array<String>] Error description
|
|
@@ -45,6 +56,7 @@ module Parsanol
|
|
|
45
56
|
@position = position
|
|
46
57
|
@children = children.nil? ? [] : children
|
|
47
58
|
@parsing_label = nil
|
|
59
|
+
@trivia = false
|
|
48
60
|
end
|
|
49
61
|
|
|
50
62
|
# Factory method for creating a cause
|
|
@@ -89,6 +101,24 @@ module Parsanol
|
|
|
89
101
|
output.string
|
|
90
102
|
end
|
|
91
103
|
|
|
104
|
+
# Stand-in for failures inside injected skip trivia: the reporting
|
|
105
|
+
# context returns it instead of calling the reporter (reporters and
|
|
106
|
+
# deepest-failure tracking never see trivia failures), and rendered
|
|
107
|
+
# trees prune it (visible).
|
|
108
|
+
class TriviaCause < Cause
|
|
109
|
+
include Singleton
|
|
110
|
+
|
|
111
|
+
def initialize
|
|
112
|
+
super("trivia", nil, 0)
|
|
113
|
+
@trivia = true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Label propagation would mutate the shared instance.
|
|
117
|
+
def set_label(_label)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
92
122
|
# Raises a ParseFailed exception with this cause's information
|
|
93
123
|
#
|
|
94
124
|
# @raise [Parsanol::ParseFailed] Always
|
|
@@ -99,12 +129,18 @@ module Parsanol
|
|
|
99
129
|
|
|
100
130
|
private
|
|
101
131
|
|
|
132
|
+
# Trivia causes stay out of rendered trees entirely.
|
|
133
|
+
def visible(node)
|
|
134
|
+
node.children.reject(&:trivia?)
|
|
135
|
+
end
|
|
136
|
+
|
|
102
137
|
def build_tree_recursive(node, stream, prefix_flags)
|
|
103
138
|
render_prefix(stream, prefix_flags)
|
|
104
139
|
stream.puts node.to_s
|
|
105
140
|
|
|
106
|
-
|
|
107
|
-
|
|
141
|
+
children = visible(node)
|
|
142
|
+
children.each do |child|
|
|
143
|
+
is_last_child = (children.last == child)
|
|
108
144
|
build_tree_recursive(child, stream, prefix_flags + [is_last_child])
|
|
109
145
|
end
|
|
110
146
|
end
|
data/lib/parsanol/native/ffi.rb
CHANGED
|
@@ -43,8 +43,18 @@ module Parsanol
|
|
|
43
43
|
json = ::Parsanol::Native::Parser.serialize_grammar(grammar)
|
|
44
44
|
atom = grammar
|
|
45
45
|
end
|
|
46
|
-
handle =
|
|
47
|
-
|
|
46
|
+
handle = @handles && @handles[json]
|
|
47
|
+
if handle
|
|
48
|
+
# Refresh recency: reinsertion moves the entry to the end,
|
|
49
|
+
# so churn cannot evict a hot grammar's handle.
|
|
50
|
+
@handles[json] = @handles.delete(json)
|
|
51
|
+
else
|
|
52
|
+
handle = @binding.c_register(json)
|
|
53
|
+
raise Error, "grammar registration failed" if handle.zero?
|
|
54
|
+
|
|
55
|
+
(@handles ||= {})[json] = handle
|
|
56
|
+
trim_handles
|
|
57
|
+
end
|
|
48
58
|
|
|
49
59
|
written = parse_into(handle, input)
|
|
50
60
|
if written.positive?
|
|
@@ -65,6 +75,19 @@ module Parsanol
|
|
|
65
75
|
|
|
66
76
|
private
|
|
67
77
|
|
|
78
|
+
# FIFO cap with recency refresh, mirroring Parser::CACHE_LIMIT.
|
|
79
|
+
# Without it, every grammar ever parsed kept its Rust-side
|
|
80
|
+
# HandleEntry (parsed Grammar + engine state) forever, and the
|
|
81
|
+
# map keys are full grammar JSON strings. Evicted handles are
|
|
82
|
+
# released through c_release; a later miss re-registers.
|
|
83
|
+
def trim_handles
|
|
84
|
+
while @handles.size > ::Parsanol::Native::Parser::CACHE_LIMIT
|
|
85
|
+
json, handle = @handles.first
|
|
86
|
+
@handles.delete(json)
|
|
87
|
+
@binding.c_release(handle)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
68
91
|
# Search order: explicit override, gem-vendored cdylib next to
|
|
69
92
|
# this file, then the system linker path.
|
|
70
93
|
def locate_library
|
|
@@ -110,8 +133,10 @@ module Parsanol
|
|
|
110
133
|
%i[uint64], :void
|
|
111
134
|
end
|
|
112
135
|
# Probe the symbols now so a mismatched library fails loudly
|
|
113
|
-
# at availability time, not at first parse.
|
|
136
|
+
# at availability time, not at first parse. c_release(0) is a
|
|
137
|
+
# no-op (unknown handle), so the probe has no side effect.
|
|
114
138
|
probe = binding_mod.c_last_error
|
|
139
|
+
binding_mod.c_release(0)
|
|
115
140
|
@binding = binding_mod
|
|
116
141
|
@buffer = nil
|
|
117
142
|
!probe.nil? || true
|
|
@@ -6,7 +6,21 @@ module Parsanol
|
|
|
6
6
|
module Native
|
|
7
7
|
# Core parsing functionality using Rust native extension
|
|
8
8
|
module Parser
|
|
9
|
-
|
|
9
|
+
# Cap for the grammar-keyed caches. The handle cache pins a
|
|
10
|
+
# Rust-side HandleEntry (parsed Grammar + compiled program) per
|
|
11
|
+
# live entry — without the cap, every grammar ever registered
|
|
12
|
+
# accumulates on both sides of the FFI boundary. 32 mirrors
|
|
13
|
+
# VM::PROGRAM_CACHE_LIMIT; eviction re-registers on demand (the
|
|
14
|
+
# Rust artifact cache short-circuits the re-compile).
|
|
15
|
+
CACHE_LIMIT = 32
|
|
16
|
+
|
|
17
|
+
# Identity-keyed memo: root atom object => structure hash. Keyed
|
|
18
|
+
# by the OBJECT, not object_id — a recycled id must never return a
|
|
19
|
+
# dead grammar's hash for a new grammar (through GRAMMAR_CACHE /
|
|
20
|
+
# HANDLE_CACHE that would alias the new grammar to the old
|
|
21
|
+
# grammar's serialized JSON and Rust handle: a silent wrong
|
|
22
|
+
# native parse).
|
|
23
|
+
GRAMMAR_HASH_CACHE = Hash.new.compare_by_identity
|
|
10
24
|
GRAMMAR_CACHE = Hash.new
|
|
11
25
|
# structure-hash => Rust-side grammar handle. Keyed by content so a
|
|
12
26
|
# recycled object_id can never alias a different grammar.
|
|
@@ -72,7 +86,18 @@ module Parsanol
|
|
|
72
86
|
# access — no JSON marshal, no re-hash inside Rust.
|
|
73
87
|
def grammar_handle(root_atom)
|
|
74
88
|
cache_key = grammar_cache_key(root_atom)
|
|
75
|
-
HANDLE_CACHE[cache_key]
|
|
89
|
+
handle = HANDLE_CACHE[cache_key]
|
|
90
|
+
if handle
|
|
91
|
+
# Refresh recency: reinsertion moves the entry to the end,
|
|
92
|
+
# so churn cannot evict a hot grammar's handle.
|
|
93
|
+
HANDLE_CACHE[cache_key] = HANDLE_CACHE.delete(cache_key)
|
|
94
|
+
return handle
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
handle = Native._register_grammar(grammar_json(root_atom))
|
|
98
|
+
HANDLE_CACHE[cache_key] = handle
|
|
99
|
+
trim_caches
|
|
100
|
+
handle
|
|
76
101
|
end
|
|
77
102
|
|
|
78
103
|
# Drop a handle whose Rust-side entry no longer exists; the next
|
|
@@ -82,6 +107,7 @@ module Parsanol
|
|
|
82
107
|
end
|
|
83
108
|
|
|
84
109
|
def clear_cache
|
|
110
|
+
HANDLE_CACHE.each_value { |handle| Native._release_grammar(handle) }
|
|
85
111
|
GRAMMAR_HASH_CACHE.clear
|
|
86
112
|
GRAMMAR_CACHE.clear
|
|
87
113
|
HANDLE_CACHE.clear
|
|
@@ -99,14 +125,38 @@ module Parsanol
|
|
|
99
125
|
|
|
100
126
|
def grammar_cache_key(root_atom)
|
|
101
127
|
root_atom = root_atom.root if root_atom.is_a?(::Parsanol::Parser)
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
cached = GRAMMAR_HASH_CACHE[root_atom]
|
|
129
|
+
return cached if cached
|
|
130
|
+
|
|
131
|
+
hash = grammar_structure_hash(root_atom)
|
|
132
|
+
GRAMMAR_HASH_CACHE[root_atom] = hash
|
|
133
|
+
GRAMMAR_HASH_CACHE.shift while GRAMMAR_HASH_CACHE.size > CACHE_LIMIT
|
|
134
|
+
hash
|
|
104
135
|
end
|
|
105
136
|
|
|
106
137
|
def grammar_json(root_atom)
|
|
107
138
|
root_atom = root_atom.root if root_atom.is_a?(::Parsanol::Parser)
|
|
108
139
|
cache_key = grammar_cache_key(root_atom)
|
|
109
|
-
GRAMMAR_CACHE[cache_key]
|
|
140
|
+
json = GRAMMAR_CACHE[cache_key]
|
|
141
|
+
return json if json
|
|
142
|
+
|
|
143
|
+
json = GrammarSerializer.serialize(root_atom)
|
|
144
|
+
GRAMMAR_CACHE[cache_key] = json
|
|
145
|
+
json
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# FIFO eviction past the cap; evicted handles are released
|
|
149
|
+
# Rust-side so HandleEntries (Grammar + program) do not
|
|
150
|
+
# accumulate. Re-registration on a later miss is cheap: the
|
|
151
|
+
# Rust artifact cache reloads the compiled program.
|
|
152
|
+
def trim_caches
|
|
153
|
+
while HANDLE_CACHE.size > CACHE_LIMIT
|
|
154
|
+
key, handle = HANDLE_CACHE.first
|
|
155
|
+
HANDLE_CACHE.delete(key)
|
|
156
|
+
GRAMMAR_CACHE.delete(key)
|
|
157
|
+
Native._release_grammar(handle)
|
|
158
|
+
end
|
|
159
|
+
GRAMMAR_CACHE.shift while GRAMMAR_CACHE.size > CACHE_LIMIT
|
|
110
160
|
end
|
|
111
161
|
|
|
112
162
|
def grammar_structure_hash(atom)
|
|
@@ -92,7 +92,11 @@ module Parsanol
|
|
|
92
92
|
def atom_for(name)
|
|
93
93
|
@atom_cache[name] ||= begin
|
|
94
94
|
atom = build_atom(fetch_rule(name))
|
|
95
|
-
|
|
95
|
+
# Atomic rules build without injection entirely: token-shaped
|
|
96
|
+
# rules match contiguously (no inter-iteration trivia, no
|
|
97
|
+
# entry-boundary skips).
|
|
98
|
+
if @document.skip_declaration && !skip_exempt_names.include?(name) &&
|
|
99
|
+
!@document.atomic_rules.include?(name)
|
|
96
100
|
atom = inject_skip_trivia(atom)
|
|
97
101
|
# Entry points take leading and trailing skips: tail trivia
|
|
98
102
|
# (the tail-comment case) has no following terminal to
|
|
@@ -120,7 +124,8 @@ module Parsanol
|
|
|
120
124
|
case node.kind
|
|
121
125
|
when :ref then names << node.a
|
|
122
126
|
when :alt, :seq then node.a.each { |b| walk_refs.call(b) }
|
|
123
|
-
when :cap then walk_refs.call(node.b)
|
|
127
|
+
when :cap, :pred then walk_refs.call(node.b)
|
|
128
|
+
when :rep, :opt then walk_refs.call(node.a)
|
|
124
129
|
end
|
|
125
130
|
end
|
|
126
131
|
walk_refs.call(@document.skip_declaration) if @document.skip_declaration
|
|
@@ -153,10 +158,12 @@ module Parsanol
|
|
|
153
158
|
"skip rule must be non-nullable: a skip that can match " \
|
|
154
159
|
"empty loops forever at injection points"
|
|
155
160
|
end
|
|
156
|
-
#
|
|
161
|
+
# Trivia: consumed but contributes nothing to the enclosing
|
|
157
162
|
# sequence/repetition value — trivia never leaks into
|
|
158
|
-
# span-joined captures
|
|
159
|
-
|
|
163
|
+
# span-joined captures — and failures inside it never surface
|
|
164
|
+
# in diagnostics (rendered trees and deepest-failure
|
|
165
|
+
# positions skip trivia causes).
|
|
166
|
+
wrapper = Atoms::Trivia.new(Atoms::Repetition.new(atom, 0, 1))
|
|
160
167
|
(@skip_wrapper_ids ||= {}.compare_by_identity)[wrapper] = true
|
|
161
168
|
wrapper
|
|
162
169
|
end
|
|
@@ -184,19 +191,34 @@ module Parsanol
|
|
|
184
191
|
when Atoms::Alternative
|
|
185
192
|
Atoms::Alternative.new(*atom.alternatives.map { |a| inject_skip_trivia(a) })
|
|
186
193
|
when Atoms::Repetition
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
# Inside repetition bodies, trivia is injected only before
|
|
195
|
+
# rule-reference children: structural iterations
|
|
196
|
+
# (`( sep item )*`) still span trivia between iterations,
|
|
197
|
+
# while char-scan spans (`( !"x" ANY )*` quoted-string bodies,
|
|
198
|
+
# WORD runs, comment bodies) stay verbatim — injecting there
|
|
199
|
+
# corrupted them (spaces inside strings eaten as whitespace,
|
|
200
|
+
# `//` inside a URL read as a comment). A single-ref body
|
|
201
|
+
# (`1*pair`) needs no interior injection: each iteration's own
|
|
202
|
+
# rule-level leading injection consumes the trivia.
|
|
203
|
+
body = atom.parslet
|
|
204
|
+
if body.is_a?(Atoms::Sequence) &&
|
|
205
|
+
body.parslets.any?(Atoms::Entity)
|
|
206
|
+
kids = body.parslets.flat_map do |child|
|
|
207
|
+
child.is_a?(Atoms::Entity) ? [skip_maybe_atom, child] : child
|
|
208
|
+
end
|
|
209
|
+
rebuilt = kids.length == 1 ? kids.first : Atoms::Sequence.new(*kids)
|
|
210
|
+
Atoms::Repetition.new(rebuilt, atom.min, atom.max, atom.result_tag)
|
|
189
211
|
else
|
|
190
|
-
|
|
191
|
-
atom.result_tag)
|
|
212
|
+
atom
|
|
192
213
|
end
|
|
193
214
|
when Atoms::Named
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
215
|
+
# Descend, keeping the capture's span clean: injected skips
|
|
216
|
+
# are Ignored-wrapped, so the bytes they consume never join
|
|
217
|
+
# span-joined captures. Composites must interleave around
|
|
218
|
+
# their own children (refs included) — the old pass-through
|
|
219
|
+
# relied on every referenced leaf self-injecting, which
|
|
220
|
+
# `atomic` rules deliberately stop doing.
|
|
221
|
+
Atoms::Named.new(inject_skip_trivia(atom.parslet), atom.name)
|
|
200
222
|
when Atoms::Lookahead
|
|
201
223
|
Atoms::Lookahead.new(inject_skip_trivia(atom.bound_parslet), atom.positive)
|
|
202
224
|
when Atoms::Str, Atoms::Re
|
|
@@ -9,7 +9,8 @@ module Parsanol
|
|
|
9
9
|
|
|
10
10
|
Test = Struct.new(:entry, :kind, :input, :expect)
|
|
11
11
|
|
|
12
|
-
attr_accessor :grammar_name, :version, :source, :uses, :skip_declaration, :skip_source
|
|
12
|
+
attr_accessor :grammar_name, :version, :source, :uses, :skip_declaration, :skip_source,
|
|
13
|
+
:atomic_rules
|
|
13
14
|
attr_reader :rules, :bindings, :preprocess, :entries, :docs, :tests, :render, :derive,
|
|
14
15
|
:customs, :states
|
|
15
16
|
# Entries authored in this file (pre-import); nil-entry tests resolve
|
|
@@ -32,6 +33,10 @@ module Parsanol
|
|
|
32
33
|
@own_entries = nil
|
|
33
34
|
@customs = {}
|
|
34
35
|
@states = {}
|
|
36
|
+
# Rules declared `atomic <name> = ...`: skip-trivia injection is
|
|
37
|
+
# suppressed inside their bodies (token-shaped rules match
|
|
38
|
+
# contiguously).
|
|
39
|
+
@atomic_rules = []
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def validate!
|
data/lib/parsanol/parg/parser.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Parsanol
|
|
|
21
21
|
STATE_KEYWORDS = %w[custom state].freeze
|
|
22
22
|
KEYWORDS = %w[grammar as alt from_table column bindings render derive
|
|
23
23
|
preprocess entry table_lookup custom state set switch
|
|
24
|
-
skip until].freeze
|
|
24
|
+
skip until atomic].freeze
|
|
25
25
|
|
|
26
26
|
def initialize(text)
|
|
27
27
|
@tokens = Lexer.new(text).tokens
|
|
@@ -188,6 +188,11 @@ module Parsanol
|
|
|
188
188
|
skip_newlines
|
|
189
189
|
next
|
|
190
190
|
end
|
|
191
|
+
# `atomic <rule> = ...` suppresses skip-trivia injection inside
|
|
192
|
+
# the rule body: token-shaped rules match their input
|
|
193
|
+
# contiguously instead of spanning trivia between iterations.
|
|
194
|
+
atomic = peek&.type == :ident && peek.value == "atomic"
|
|
195
|
+
advance if atomic
|
|
191
196
|
name = rule_name
|
|
192
197
|
document.docs[name] = docs.join("\n") unless docs.empty?
|
|
193
198
|
punct("=")
|
|
@@ -197,6 +202,7 @@ module Parsanol
|
|
|
197
202
|
end
|
|
198
203
|
|
|
199
204
|
document.rules[name] = node
|
|
205
|
+
document.atomic_rules << name if atomic
|
|
200
206
|
skip_newlines
|
|
201
207
|
end
|
|
202
208
|
punct("}")
|
data/lib/parsanol/version.rb
CHANGED
data/lib/parsanol/vm.rb
CHANGED
|
@@ -84,12 +84,35 @@ module Parsanol
|
|
|
84
84
|
STEP_BUDGET_FIXED = 10_000
|
|
85
85
|
MEMO_BUDGET_FACTOR = 4_096
|
|
86
86
|
MEMO_BUDGET_CELLS_FACTOR = 16
|
|
87
|
+
# Wall-clock ceiling for the memoized retry pass. Step budgets assume
|
|
88
|
+
# sub-microsecond steps; pathological grammars make each step
|
|
89
|
+
# expensive enough that a step budget alone mispredicts wall time by
|
|
90
|
+
# orders of magnitude (parsanol-ruby#147: a 6-line skip-injected
|
|
91
|
+
# document ground ~146s inside its memo budget where the interpreter
|
|
92
|
+
# answers in milliseconds). Bounded wall time surrenders the grammar
|
|
93
|
+
# to the interpreter, the source of truth, instead of burning CPU.
|
|
94
|
+
MEMO_WALL_BUDGET_SECONDS = 5.0
|
|
95
|
+
|
|
96
|
+
# Compiled-program and heavy-flag caches hold at most this many
|
|
97
|
+
# grammars. A process rarely uses more than a handful concurrently;
|
|
98
|
+
# the cap keeps worst-case retention explicit and bounded instead
|
|
99
|
+
# of growing with every grammar ever parsed.
|
|
100
|
+
PROGRAM_CACHE_LIMIT = 32
|
|
87
101
|
|
|
88
102
|
class << self
|
|
89
|
-
|
|
103
|
+
# Compiled-program and heavy-flag caches, keyed by the root atom
|
|
104
|
+
# object itself (identity semantics — two structurally identical
|
|
105
|
+
# grammars are distinct entries). This replaces the original
|
|
106
|
+
# object_id-keyed plain Hash, where entries outlived their
|
|
107
|
+
# grammars forever and a recycled object_id could serve a fresh
|
|
108
|
+
# grammar another grammar's program. Holding the object as the
|
|
109
|
+
# key does pin the grammar while cached — bounded by
|
|
110
|
+
# PROGRAM_CACHE_LIMIT, FIFO eviction.
|
|
111
|
+
@programs = {}.compare_by_identity
|
|
112
|
+
@heavy = {}.compare_by_identity
|
|
90
113
|
|
|
91
114
|
# Cached compile keyed by root-atom object identity. Atoms are
|
|
92
|
-
# effectively immutable once constructed;
|
|
115
|
+
# effectively immutable once constructed; nil caches grammars the
|
|
93
116
|
# VM cannot handle so we do not re-walk them on every parse.
|
|
94
117
|
#
|
|
95
118
|
# A grammar whose VM run failed once (unsupported backtracking shape
|
|
@@ -97,29 +120,35 @@ module Parsanol
|
|
|
97
120
|
# attempt entirely — the interpreter result is identical.
|
|
98
121
|
def program_for(atom)
|
|
99
122
|
root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
104
|
-
return cached if key?(cache, key)
|
|
123
|
+
cache = (@programs ||= {}.compare_by_identity)
|
|
124
|
+
cached = cache[root]
|
|
125
|
+
return nil if cached.equal?(:fallback)
|
|
126
|
+
return cached if key?(cache, root)
|
|
105
127
|
|
|
106
128
|
# A grammar the compiler cannot compile — e.g. one containing a
|
|
107
129
|
# lazily resolved Entity that a select-first literal index would
|
|
108
130
|
# never select — falls back to the interpreter, the source of
|
|
109
131
|
# truth. The interpreter resolves such entities lazily, so an
|
|
110
132
|
# unselected branch simply never raises.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
program =
|
|
134
|
+
begin
|
|
135
|
+
compile(atom)
|
|
136
|
+
rescue NotImplementedError
|
|
137
|
+
:fallback
|
|
138
|
+
end
|
|
139
|
+
cache[root] = program
|
|
140
|
+
trim_cache(cache)
|
|
141
|
+
# :fallback = VM-incompatible; :oversize even non-inlined means
|
|
142
|
+
# the grammar is beyond the program cap — the interpreter
|
|
143
|
+
# handles both.
|
|
144
|
+
program.equal?(:fallback) || program.equal?(:oversize) ? nil : program
|
|
117
145
|
end
|
|
118
146
|
|
|
119
147
|
# Marks a grammar as VM-incompatible after a runtime failure.
|
|
120
148
|
def disable_for!(atom)
|
|
121
149
|
root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
|
|
122
|
-
(@programs ||= {})[root
|
|
150
|
+
(@programs ||= {}.compare_by_identity)[root] = :fallback
|
|
151
|
+
trim_cache(@programs)
|
|
123
152
|
end
|
|
124
153
|
|
|
125
154
|
def key?(cache, key) # rubocop:disable Naming/PredicateMethod -- mirrors Hash#key?
|
|
@@ -131,6 +160,12 @@ module Parsanol
|
|
|
131
160
|
@heavy&.clear
|
|
132
161
|
end
|
|
133
162
|
|
|
163
|
+
# FIFO eviction past the cache limit.
|
|
164
|
+
def trim_cache(cache)
|
|
165
|
+
cache.shift while cache.size > PROGRAM_CACHE_LIMIT
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
134
169
|
# Compiles the grammar rooted at +atom+. Returns the flat program
|
|
135
170
|
# Array, or nil when the grammar uses unsupported atoms.
|
|
136
171
|
def compile(atom)
|
|
@@ -168,11 +203,15 @@ module Parsanol
|
|
|
168
203
|
# included — the seed is recomputed at every compile), so the
|
|
169
204
|
# cold-start never pays the doomed unmemoized exploration.
|
|
170
205
|
if compiler.backtracking_prone
|
|
171
|
-
|
|
172
|
-
(@heavy
|
|
206
|
+
(@heavy ||= {}.compare_by_identity)[root] = true
|
|
207
|
+
trim_cache(@heavy)
|
|
173
208
|
end
|
|
174
209
|
|
|
175
210
|
compiler.to_program
|
|
211
|
+
rescue Compiler::ProgramTooLarge
|
|
212
|
+
# Emission aborted at the cap: discard and let the caller
|
|
213
|
+
# decide (compile retries non-inlined).
|
|
214
|
+
:oversize
|
|
176
215
|
end
|
|
177
216
|
|
|
178
217
|
# Returns violation descriptions for this atom's ordered-choice
|
|
@@ -194,6 +233,13 @@ module Parsanol
|
|
|
194
233
|
break if walk_branch.nil?
|
|
195
234
|
end
|
|
196
235
|
next if walk_branch.nil?
|
|
236
|
+
# A bare zero-width assertion is a guard, not a content
|
|
237
|
+
# matcher: `&X / rest` commits to its empty match exactly
|
|
238
|
+
# where X succeeds, and `rest` is reachable exactly where X
|
|
239
|
+
# fails — conditional control flow, not accidental shadowing
|
|
240
|
+
# (#137). Both engines already stop repetitions on
|
|
241
|
+
# zero-width iterations, so guard branches cannot loop.
|
|
242
|
+
next if walk_branch.is_a?(Parsanol::Atoms::Lookahead)
|
|
197
243
|
next unless Compiler.new.nullable?(walk_branch)
|
|
198
244
|
|
|
199
245
|
"alternative branch #{idx} (#{branch.inspect}) can match " \
|
|
@@ -287,9 +333,8 @@ module Parsanol
|
|
|
287
333
|
# interpreter.
|
|
288
334
|
def run_for(atom, program, input, consume_all)
|
|
289
335
|
root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if heavy[id]
|
|
336
|
+
heavy = (@heavy ||= {}.compare_by_identity)
|
|
337
|
+
if heavy[root]
|
|
293
338
|
result = Executor.new(program, input, consume_all,
|
|
294
339
|
memoize: true).execute
|
|
295
340
|
return BAIL if result.equal?(BUDGET)
|
|
@@ -300,7 +345,8 @@ module Parsanol
|
|
|
300
345
|
result = Executor.new(program, input, consume_all).execute
|
|
301
346
|
if result.equal?(BUDGET) ||
|
|
302
347
|
(result.is_a?(Array) && result.first == :heavy)
|
|
303
|
-
heavy[
|
|
348
|
+
heavy[root] = true
|
|
349
|
+
trim_cache(heavy)
|
|
304
350
|
memo_result = Executor.new(program, input, consume_all,
|
|
305
351
|
memoize: true).execute
|
|
306
352
|
# A heavy naive success already answered; the memo pass only
|
|
@@ -648,7 +694,19 @@ module Parsanol
|
|
|
648
694
|
|
|
649
695
|
private
|
|
650
696
|
|
|
697
|
+
# Raised mid-emission once the program passes MAX_PROGRAM: abort
|
|
698
|
+
# the build immediately instead of materializing millions of
|
|
699
|
+
# instructions only to discover the overflow in to_program
|
|
700
|
+
# (parsanol-ruby#147: an inline pass on a skip-injected grammar
|
|
701
|
+
# reached 6.3M instructions / 16M with subroutines before the
|
|
702
|
+
# post-hoc check discarded them — minutes of compile time and
|
|
703
|
+
# gigabytes of transient allocations for a program that is
|
|
704
|
+
# thrown away and rebuilt non-inlined anyway).
|
|
705
|
+
class ProgramTooLarge < StandardError; end
|
|
706
|
+
|
|
651
707
|
def emit(op, a = nil, b = nil, c = nil) # rubocop:disable Naming/MethodParameterName -- opcode operand slots
|
|
708
|
+
raise ProgramTooLarge if @ops.size > MAX_PROGRAM
|
|
709
|
+
|
|
652
710
|
@ops << [op, a, b, c]
|
|
653
711
|
@ops.size - 1
|
|
654
712
|
end
|
|
@@ -871,6 +929,9 @@ module Parsanol
|
|
|
871
929
|
(STEP_BUDGET_FACTOR * n) + STEP_BUDGET_FIXED
|
|
872
930
|
end
|
|
873
931
|
steps = 0
|
|
932
|
+
wall_deadline = if @memoize
|
|
933
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + MEMO_WALL_BUDGET_SECONDS
|
|
934
|
+
end
|
|
874
935
|
|
|
875
936
|
pc = 0
|
|
876
937
|
pos = 0
|
|
@@ -886,6 +947,13 @@ module Parsanol
|
|
|
886
947
|
# rubocop:disable Metrics/BlockLength -- the dispatch loop IS execute
|
|
887
948
|
loop do
|
|
888
949
|
steps += 1
|
|
950
|
+
# Checked every 1024 steps: a monotonic clock read per
|
|
951
|
+
# millisecond of ordinary work costs nothing measurable.
|
|
952
|
+
if wall_deadline && steps.nobits?(0x3FF) &&
|
|
953
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) > wall_deadline
|
|
954
|
+
return BUDGET
|
|
955
|
+
end
|
|
956
|
+
|
|
889
957
|
if steps > budget
|
|
890
958
|
if trace
|
|
891
959
|
warn "BUDGET at steps=#{steps} pc=#{pc} pos=#{pos} rstack=#{rstack.size} bt=#{bt.size} calls=#{calls.size}"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: parsanol
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.3.
|
|
4
|
+
version: 1.3.69
|
|
5
5
|
platform: arm-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A small Ruby library for constructing parsers in the PEG (Parsing Expression
|
|
14
14
|
Grammar) fashion. Parsanol provides Parslet-compatible API with additional features
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- lib/parsanol/atoms/sequence.rb
|
|
55
55
|
- lib/parsanol/atoms/state.rb
|
|
56
56
|
- lib/parsanol/atoms/str.rb
|
|
57
|
+
- lib/parsanol/atoms/trivia.rb
|
|
57
58
|
- lib/parsanol/atoms/visitor.rb
|
|
58
59
|
- lib/parsanol/buffer.rb
|
|
59
60
|
- lib/parsanol/builder_callbacks.rb
|