parsanol 1.3.66 → 1.3.68

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: da894739a391336f9b9f65a65abc87da1fe51b3f3268c0c2334638e53f5adb8f
4
- data.tar.gz: 6777a602a63a37e599fdc1ed9c38f82eb8b01a30a12a23c24bbed7518f37e5cf
3
+ metadata.gz: 5917e9971751a101c27984a0ccec6aa6254813b77d18a45eea7f6a5538ead517
4
+ data.tar.gz: 9513f097d858d7c394a10c8002e372523212bf11629813867ec59a91dacbc41d
5
5
  SHA512:
6
- metadata.gz: 0fad1aa5febfc712a767578c4ca4d9c9c6cf3def48ed7f6fe97a66a95e45d0e628a4285660eb14b40e6d0b72bbd6422868b85ea901d84318382f23aa5744378b
7
- data.tar.gz: 6ab9b3b4461592a3a5e91accd57d0341c59c7f878985b01fd89b73fe91c8abd71eacedd6246d0c79a2fd57547607415c0932e73cfb2c2e61b3adb2af5165a9a6
6
+ metadata.gz: 65cf06e07d16879100effb50a7ed8012e95774ed6b8c86f97c997d3b6ce7a41525a07e3943d4670dc3c6f65e91d587953e719668decba35f90610bdf29a7c011
7
+ data.tar.gz: 84cd53317231f028e6510d90c7c426379566e47570594e960b022766ae45fffb8305afdcc5f044339fbbdb3a30c74e44e25ce03a735c7e3672221f70f26b0ab1
@@ -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 [false, @reporter.err_at(*)] if @reporter
244
+ return TRIVIA_ERROR if @trivia_depth.positive?
245
+ return ERROR_RESULT unless @reporter
240
246
 
241
- ERROR_RESULT
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 [false, @reporter.err(*)] if @reporter
255
+ return TRIVIA_ERROR if @trivia_depth.positive?
256
+ return ERROR_RESULT unless @reporter
257
+
258
+ [false, @reporter.err(*)]
259
+ end
250
260
 
251
- ERROR_RESULT
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
- @parslets.map { |p| p.to_s(prec) }.join(" ")
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
@@ -27,6 +27,7 @@ module Parsanol
27
27
  require "parsanol/atoms/base"
28
28
  require "parsanol/atoms/custom"
29
29
  require "parsanol/atoms/ignored"
30
+ require "parsanol/atoms/trivia"
30
31
  require "parsanol/atoms/named"
31
32
  require "parsanol/atoms/lookahead"
32
33
  require "parsanol/atoms/cut"
@@ -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
- node.children.each do |child|
107
- is_last_child = (node.children.last == child)
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
@@ -43,8 +43,18 @@ module Parsanol
43
43
  json = ::Parsanol::Native::Parser.serialize_grammar(grammar)
44
44
  atom = grammar
45
45
  end
46
- handle = ((@handles ||= {})[json] ||= @binding.c_register(json))
47
- raise Error, "grammar registration failed" if handle.zero?
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
- GRAMMAR_HASH_CACHE = Hash.new
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] ||= Native._register_grammar(grammar_json(root_atom))
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
- obj_id = root_atom.object_id
103
- GRAMMAR_HASH_CACHE[obj_id] ||= grammar_structure_hash(root_atom)
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] ||= GrammarSerializer.serialize(root_atom)
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)
@@ -55,6 +55,17 @@ module Parsanol
55
55
  @warnings = []
56
56
  end
57
57
 
58
+ # QoL shorthands (ABNF core rules): materialized on reference when
59
+ # the grammar does not define its own rule of that name.
60
+ BUILTIN_CLASSES = {
61
+ "DIGIT" => [[0x30, 0x39]],
62
+ "ALPHA" => [[0x41, 0x5A], [0x61, 0x7A]],
63
+ "ALNUM" => [[0x30, 0x39], [0x41, 0x5A], [0x61, 0x7A]],
64
+ "WORD" => [[0x30, 0x39], [0x41, 0x5A], [0x5F, 0x5F], [0x61, 0x7A]],
65
+ "HEXDIG" => [[0x30, 0x39], [0x41, 0x46], [0x61, 0x66]],
66
+ "ANY" => [[0x00, 0x10FFFF]],
67
+ }.freeze
68
+
58
69
  def compile
59
70
  @document.rules.each_key { |name| atom_for(name) }
60
71
  errors = Lints.errors(@document, self)
@@ -79,7 +90,146 @@ module Parsanol
79
90
  end
80
91
 
81
92
  def atom_for(name)
82
- @atom_cache[name] ||= build_atom(fetch_rule(name))
93
+ @atom_cache[name] ||= begin
94
+ atom = build_atom(fetch_rule(name))
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)
100
+ atom = inject_skip_trivia(atom)
101
+ # Entry points take leading and trailing skips: tail trivia
102
+ # (the tail-comment case) has no following terminal to
103
+ # trigger an injection, and full-consumption would reject
104
+ # it; leading trivia has no preceding interleave either.
105
+ if entry_rule?(name)
106
+ atom = Atoms::Sequence.new(skip_maybe_atom, atom, skip_maybe_atom)
107
+ end
108
+ end
109
+ atom
110
+ end
111
+ end
112
+
113
+ def entry_rule?(name)
114
+ @document.entries.value?(name) || @document.own_entries&.include?(name)
115
+ end
116
+
117
+ # The skip declaration's rule references (and their transitive
118
+ # rule references) build without injection: the skip atom must
119
+ # not contain itself.
120
+ def skip_exempt_names
121
+ @skip_exempt_names ||= begin
122
+ names = Set.new
123
+ walk_refs = lambda do |node|
124
+ case node.kind
125
+ when :ref then names << node.a
126
+ when :alt, :seq then node.a.each { |b| walk_refs.call(b) }
127
+ when :cap, :pred then walk_refs.call(node.b)
128
+ when :rep, :opt then walk_refs.call(node.a)
129
+ end
130
+ end
131
+ walk_refs.call(@document.skip_declaration) if @document.skip_declaration
132
+ closed = Set.new
133
+ until names.subset?(closed)
134
+ (names - closed).each do |ref|
135
+ closed << ref
136
+ walk_refs.call(@document.rules[ref]) if @document.rules.key?(ref)
137
+ end
138
+ names += closed
139
+ end
140
+ names
141
+ end
142
+ end
143
+
144
+ def skip_wrapper?(atom)
145
+ @skip_wrapper_ids&.key?(atom)
146
+ end
147
+
148
+ def skip_maybe_atom
149
+ @skip_maybe_atom ||= begin
150
+ atom = build_atom(@document.skip_declaration)
151
+ while atom.is_a?(Atoms::Entity)
152
+ atom = atom.parslet
153
+ end
154
+ # the VM's internal compiler owns the ATOM-level nullability
155
+ # analysis (the node-level nullable? here works on rule nodes)
156
+ if Parsanol::VM::Compiler.new.nullable?(atom)
157
+ raise CompileError,
158
+ "skip rule must be non-nullable: a skip that can match " \
159
+ "empty loops forever at injection points"
160
+ end
161
+ # Trivia: consumed but contributes nothing to the enclosing
162
+ # sequence/repetition value — trivia never leaks into
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))
167
+ (@skip_wrapper_ids ||= {}.compare_by_identity)[wrapper] = true
168
+ wrapper
169
+ end
170
+ end
171
+
172
+ # parsanol-ruby#134: inject an optional match of the declared skip
173
+ # rule before every terminal of every rule. Injected skips are
174
+ # capture-free (a bare Repetition contributes no Named results),
175
+ # optional (ordered-choice/backtracking semantics preserved), and
176
+ # leave first sets unchanged (an optional prefix is epsilon-able).
177
+ def inject_skip_trivia(atom)
178
+ return atom if @document.skip_declaration.nil?
179
+
180
+ case atom
181
+ # rubocop:disable Lint/DuplicateBranch -- pass-through is the
182
+ # deliberate default; identical bodies, different decisions
183
+ when Atoms::Sequence
184
+ kids = atom.parslets.map { |child| inject_skip_trivia(child) }
185
+ out = []
186
+ kids.each do |child|
187
+ out << skip_maybe_atom unless skip_wrapper?(child)
188
+ out << child
189
+ end
190
+ out.length == 1 ? out.first : Atoms::Sequence.new(*out)
191
+ when Atoms::Alternative
192
+ Atoms::Alternative.new(*atom.alternatives.map { |a| inject_skip_trivia(a) })
193
+ when Atoms::Repetition
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)
211
+ else
212
+ atom
213
+ end
214
+ when Atoms::Named
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)
222
+ when Atoms::Lookahead
223
+ Atoms::Lookahead.new(inject_skip_trivia(atom.bound_parslet), atom.positive)
224
+ when Atoms::Str, Atoms::Re
225
+ # bare terminal as a whole rule body: leading trivia position
226
+ Atoms::Sequence.new(skip_maybe_atom, atom)
227
+ # pass-through is the deliberate default; identical bodies,
228
+ # different decisions
229
+ else
230
+ atom
231
+ end
232
+ # rubocop:enable Lint/DuplicateBranch
83
233
  end
84
234
 
85
235
  def record_warning(message)
@@ -174,6 +324,10 @@ module Parsanol
174
324
  if !@document.rules.key?(name) && @document.customs.key?(name)
175
325
  return Node.new(:lit, "\u0001", false)
176
326
  end
327
+ if !@document.rules.key?(name) && BUILTIN_CLASSES.key?(name)
328
+ # QoL shorthand stand-in: the analysis passes see the class
329
+ return Node.new(:class, BUILTIN_CLASSES[name])
330
+ end
177
331
 
178
332
  @document.rules[name] || raise(CompileError, "unknown rule #{name.inspect}")
179
333
  end
@@ -193,6 +347,15 @@ module Parsanol
193
347
  Atoms::Str.new(node.a)
194
348
  end
195
349
  when :class then Atoms::Re.new(class_pattern(node.a))
350
+ when :until
351
+ # QoL: (*( !terminal any_char )) — consume everything up to
352
+ # (and excluding) the delimiter.
353
+ any_char = Atoms::Re.new(class_pattern([[0x00, 0x10FFFF]]))
354
+ Atoms::Repetition.new(
355
+ Atoms::Sequence.new(Atoms::Lookahead.new(Atoms::Str.new(node.a), false),
356
+ any_char),
357
+ 0, nil
358
+ )
196
359
  when :seq
197
360
  atoms = node.a.map { |child| build_atom(child) }
198
361
  atoms.length == 1 ? atoms.first : Atoms::Sequence.new(*atoms)
@@ -234,6 +397,10 @@ module Parsanol
234
397
  if @document.customs.key?(node.a)
235
398
  @dynamic = true
236
399
  resolve_custom(node.a)
400
+ elsif !@document.rules.key?(node.a) && BUILTIN_CLASSES.key?(node.a)
401
+ # QoL: character-class shorthands (ABNF core rules) — usable
402
+ # unless the grammar defines its own rule of the same name.
403
+ Atoms::Re.new(class_pattern(BUILTIN_CLASSES[node.a]))
237
404
  else
238
405
  Atoms::Entity.new(node.a) { atom_for(node.a) }
239
406
  end
@@ -472,6 +639,13 @@ module Parsanol
472
639
  "docs" => @document.docs,
473
640
  "source" => @document.source,
474
641
  }
642
+ if @document.skip_declaration
643
+ # parsanol-ruby#134: declared trivia rule. Injection is baked
644
+ # into the compiled trees (plain atom kinds), so consumers
645
+ # without skip support still parse these artifacts correctly;
646
+ # the field records the grammar-level declaration.
647
+ envelope["skip"] = @document.skip_source
648
+ end
475
649
  envelope["customs"] = @document.customs unless @document.customs.empty?
476
650
  envelope["states"] = @document.states unless @document.states.empty?
477
651
  envelope["dynamic"] = true if @dynamic
@@ -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
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!
@@ -20,7 +20,8 @@ module Parsanol
20
20
 
21
21
  STATE_KEYWORDS = %w[custom state].freeze
22
22
  KEYWORDS = %w[grammar as alt from_table column bindings render derive
23
- preprocess entry table_lookup custom state set switch].freeze
23
+ preprocess entry table_lookup custom state set switch
24
+ skip until atomic].freeze
24
25
 
25
26
  def initialize(text)
26
27
  @tokens = Lexer.new(text).tokens
@@ -161,6 +162,19 @@ module Parsanol
161
162
  skip_newlines
162
163
  next
163
164
  end
165
+ if peek&.type == :ident && peek.value == "skip"
166
+ advance
167
+ punct("=")
168
+ if document.skip_declaration
169
+ raise ParseError, "duplicate skip declaration"
170
+ end
171
+
172
+ node = parse_choice
173
+ document.skip_declaration = validate_skip_node(node)
174
+ document.skip_source = skip_source_string(node)
175
+ skip_newlines
176
+ next
177
+ end
164
178
  if peek&.type == :ident && STATE_KEYWORDS.include?(peek.value)
165
179
  keyword = advance.value
166
180
  slot = ident.value
@@ -174,6 +188,11 @@ module Parsanol
174
188
  skip_newlines
175
189
  next
176
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
177
196
  name = rule_name
178
197
  document.docs[name] = docs.join("\n") unless docs.empty?
179
198
  punct("=")
@@ -183,11 +202,36 @@ module Parsanol
183
202
  end
184
203
 
185
204
  document.rules[name] = node
205
+ document.atomic_rules << name if atomic
186
206
  skip_newlines
187
207
  end
188
208
  punct("}")
189
209
  end
190
210
 
211
+ # The skip declaration may only name rules (a rule reference or an
212
+ # alternation of rule references) — arbitrary expressions would
213
+ # make the injected trivia unanalyzable.
214
+ def skip_source_string(node)
215
+ case node.kind
216
+ when :ref then node.a
217
+ when :alt then node.a.map { |b| skip_source_string(b) }.join(" / ")
218
+ end
219
+ end
220
+
221
+ def validate_skip_node(node)
222
+ case node.kind
223
+ when :ref
224
+ node
225
+ when :alt
226
+ node.a.each { |branch| validate_skip_node(branch) }
227
+ node
228
+ else
229
+ raise ParseError,
230
+ "skip must be a rule reference or an alternation of rule " \
231
+ "references, got #{node.kind.inspect}"
232
+ end
233
+ end
234
+
191
235
  def rule_name
192
236
  token = ident
193
237
  if KEYWORDS.include?(token.value)
@@ -325,6 +369,10 @@ module Parsanol
325
369
  end
326
370
  punct("}")
327
371
  Node.new(:switch, slot, arms)
372
+ when "until"
373
+ # QoL: any-until terminal — (*( !terminal any_char )), the
374
+ # idiomatic "consume everything up to the delimiter" run.
375
+ Node.new(:until, unquote(expect(:str).value))
328
376
  when "alt"
329
377
  ident("from_table")
330
378
  table = unquote(expect(:str).value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.66"
4
+ VERSION = "1.3.68"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -85,11 +85,26 @@ module Parsanol
85
85
  MEMO_BUDGET_FACTOR = 4_096
86
86
  MEMO_BUDGET_CELLS_FACTOR = 16
87
87
 
88
+ # Compiled-program and heavy-flag caches hold at most this many
89
+ # grammars. A process rarely uses more than a handful concurrently;
90
+ # the cap keeps worst-case retention explicit and bounded instead
91
+ # of growing with every grammar ever parsed.
92
+ PROGRAM_CACHE_LIMIT = 32
93
+
88
94
  class << self
89
- @programs = {}
95
+ # Compiled-program and heavy-flag caches, keyed by the root atom
96
+ # object itself (identity semantics — two structurally identical
97
+ # grammars are distinct entries). This replaces the original
98
+ # object_id-keyed plain Hash, where entries outlived their
99
+ # grammars forever and a recycled object_id could serve a fresh
100
+ # grammar another grammar's program. Holding the object as the
101
+ # key does pin the grammar while cached — bounded by
102
+ # PROGRAM_CACHE_LIMIT, FIFO eviction.
103
+ @programs = {}.compare_by_identity
104
+ @heavy = {}.compare_by_identity
90
105
 
91
106
  # Cached compile keyed by root-atom object identity. Atoms are
92
- # effectively immutable once constructed; false caches grammars the
107
+ # effectively immutable once constructed; nil caches grammars the
93
108
  # VM cannot handle so we do not re-walk them on every parse.
94
109
  #
95
110
  # A grammar whose VM run failed once (unsupported backtracking shape
@@ -97,29 +112,32 @@ module Parsanol
97
112
  # attempt entirely — the interpreter result is identical.
98
113
  def program_for(atom)
99
114
  root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
100
- key = root.object_id
101
- cache = (@programs ||= {})
102
- cached = cache[key]
103
- return nil if cached == :fallback
104
- return cached if key?(cache, key)
115
+ cache = (@programs ||= {}.compare_by_identity)
116
+ cached = cache[root]
117
+ return nil if cached.equal?(:fallback)
118
+ return cached if key?(cache, root)
105
119
 
106
120
  # A grammar the compiler cannot compile — e.g. one containing a
107
121
  # lazily resolved Entity that a select-first literal index would
108
122
  # never select — falls back to the interpreter, the source of
109
123
  # truth. The interpreter resolves such entities lazily, so an
110
124
  # unselected branch simply never raises.
111
- begin
112
- cache[key] = compile(atom)
113
- rescue NotImplementedError
114
- cache[key] = :fallback
115
- nil
116
- end
125
+ program =
126
+ begin
127
+ compile(atom)
128
+ rescue NotImplementedError
129
+ :fallback
130
+ end
131
+ cache[root] = program
132
+ trim_cache(cache)
133
+ program.equal?(:fallback) ? nil : program
117
134
  end
118
135
 
119
136
  # Marks a grammar as VM-incompatible after a runtime failure.
120
137
  def disable_for!(atom)
121
138
  root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
122
- (@programs ||= {})[root.object_id] = :fallback # rubocop:disable Lint/HashCompareByIdentity -- object_id keys avoid holding strong references to grammar atoms
139
+ (@programs ||= {}.compare_by_identity)[root] = :fallback
140
+ trim_cache(@programs)
123
141
  end
124
142
 
125
143
  def key?(cache, key) # rubocop:disable Naming/PredicateMethod -- mirrors Hash#key?
@@ -131,6 +149,12 @@ module Parsanol
131
149
  @heavy&.clear
132
150
  end
133
151
 
152
+ # FIFO eviction past the cache limit.
153
+ def trim_cache(cache)
154
+ cache.shift while cache.size > PROGRAM_CACHE_LIMIT
155
+ nil
156
+ end
157
+
134
158
  # Compiles the grammar rooted at +atom+. Returns the flat program
135
159
  # Array, or nil when the grammar uses unsupported atoms.
136
160
  def compile(atom)
@@ -168,8 +192,8 @@ module Parsanol
168
192
  # included — the seed is recomputed at every compile), so the
169
193
  # cold-start never pays the doomed unmemoized exploration.
170
194
  if compiler.backtracking_prone
171
- # rubocop:disable-next Lint/HashCompareByIdentity -- object_id keys mirror run_for's heavy flag
172
- (@heavy ||= {})[root.object_id] = true
195
+ (@heavy ||= {}.compare_by_identity)[root] = true
196
+ trim_cache(@heavy)
173
197
  end
174
198
 
175
199
  compiler.to_program
@@ -194,6 +218,13 @@ module Parsanol
194
218
  break if walk_branch.nil?
195
219
  end
196
220
  next if walk_branch.nil?
221
+ # A bare zero-width assertion is a guard, not a content
222
+ # matcher: `&X / rest` commits to its empty match exactly
223
+ # where X succeeds, and `rest` is reachable exactly where X
224
+ # fails — conditional control flow, not accidental shadowing
225
+ # (#137). Both engines already stop repetitions on
226
+ # zero-width iterations, so guard branches cannot loop.
227
+ next if walk_branch.is_a?(Parsanol::Atoms::Lookahead)
197
228
  next unless Compiler.new.nullable?(walk_branch)
198
229
 
199
230
  "alternative branch #{idx} (#{branch.inspect}) can match " \
@@ -287,9 +318,8 @@ module Parsanol
287
318
  # interpreter.
288
319
  def run_for(atom, program, input, consume_all)
289
320
  root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
290
- id = root.object_id
291
- heavy = (@heavy ||= {})
292
- if heavy[id]
321
+ heavy = (@heavy ||= {}.compare_by_identity)
322
+ if heavy[root]
293
323
  result = Executor.new(program, input, consume_all,
294
324
  memoize: true).execute
295
325
  return BAIL if result.equal?(BUDGET)
@@ -300,7 +330,8 @@ module Parsanol
300
330
  result = Executor.new(program, input, consume_all).execute
301
331
  if result.equal?(BUDGET) ||
302
332
  (result.is_a?(Array) && result.first == :heavy)
303
- heavy[id] = true
333
+ heavy[root] = true
334
+ trim_cache(heavy)
304
335
  memo_result = Executor.new(program, input, consume_all,
305
336
  memoize: true).execute
306
337
  # A heavy naive success already answered; the memo pass only
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.66
4
+ version: 1.3.68
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -72,6 +72,7 @@ files:
72
72
  - lib/parsanol/atoms/sequence.rb
73
73
  - lib/parsanol/atoms/state.rb
74
74
  - lib/parsanol/atoms/str.rb
75
+ - lib/parsanol/atoms/trivia.rb
75
76
  - lib/parsanol/atoms/visitor.rb
76
77
  - lib/parsanol/buffer.rb
77
78
  - lib/parsanol/builder_callbacks.rb