parsanol 1.3.67 → 1.3.69

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: edb8e75e8db0ef36481c9df9793ec0ac19fd83759dbd9242bc25149ea37940ac
4
- data.tar.gz: 70e2329b885f299492faeccadd4542ca3e739c2f1ea9331bada36851a3e4ebf0
3
+ metadata.gz: 263a808dabf8497185a7c4712796b5cb2e29e4318092a8b0eaeccbb5f7d4d7fd
4
+ data.tar.gz: d4fa28dc6c85cbb2b7de27e983e5920571c34f638c1ec1d2f12ed86286453a40
5
5
  SHA512:
6
- metadata.gz: 896769b8bb6887c60ab69ddfb7a971ba7a6e10577b109c5873a5cad50b77e63465c76d017ed747fb7665fb61231b47b3387418d3f3d1c690a3797ff68410fff0
7
- data.tar.gz: dc8b652f4972ff94356ef3b3a9bfe24a7503bcdf177f03f7a39160fe8f643681ca889815a5040568e12039a89d5be7c78e11614a2b3b7375392a7862b63dbd16
6
+ metadata.gz: de302ffc7dce0ad7f949267421ad6277d82096a1a3269ea60dafcf95d6383a8c35f7bd6387e0bc7ca19f065dd59589e618a1115938e4d4d2bd20cd9bc3c04947
7
+ data.tar.gz: 0741e977cb3deaba13193c67e8c37ec9e4d24b904667ddd984a7659bbcd67fd928e8256a6bb80a82e47afe47ebfdd2cf0582727e544f97c4f02a4f15f4c351e7
@@ -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)
@@ -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
- if @document.skip_declaration && !skip_exempt_names.include?(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)
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
- # Ignored: consumed but contributes nothing to the enclosing
161
+ # Trivia: consumed but contributes nothing to the enclosing
157
162
  # sequence/repetition value — trivia never leaks into
158
- # span-joined captures.
159
- wrapper = Atoms::Ignored.new(Atoms::Repetition.new(atom, 0, 1))
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
- if skip_wrapper?(atom)
188
- atom
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
- Atoms::Repetition.new(inject_skip_trivia(atom.parslet), atom.min, atom.max,
191
- atom.result_tag)
212
+ atom
192
213
  end
193
214
  when Atoms::Named
194
- # Pass-through: trivia must stay OUTSIDE captures. Injecting
195
- # inside a Named makes the captured span include the skipped
196
- # bytes (span-join swallows them into the captured value).
197
- # Inter-token trivia around a capture is injected by the
198
- # enclosing sequence's interleave.
199
- atom
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!
@@ -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("}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.67"
4
+ VERSION = "1.3.69"
5
5
  end
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
- @programs = {}
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; false caches grammars the
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
- key = root.object_id
101
- cache = (@programs ||= {})
102
- cached = cache[key]
103
- return nil if cached == :fallback
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
- begin
112
- cache[key] = compile(atom)
113
- rescue NotImplementedError
114
- cache[key] = :fallback
115
- nil
116
- end
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.object_id] = :fallback # rubocop:disable Lint/HashCompareByIdentity -- object_id keys avoid holding strong references to grammar atoms
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
- # rubocop:disable-next Lint/HashCompareByIdentity -- object_id keys mirror run_for's heavy flag
172
- (@heavy ||= {})[root.object_id] = true
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
- id = root.object_id
291
- heavy = (@heavy ||= {})
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[id] = true
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.67
4
+ version: 1.3.69
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