parsanol 1.3.63 → 1.3.65

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: f7ad59a2f852639eec49d8aaf0cfce4be63d8e13a20941b7db8f325244445e47
4
- data.tar.gz: b4f7021d4c4dd9f30df4eddf64b2109da9005291b4d39edadd5075e54c6600c0
3
+ metadata.gz: a40f12d1d7a5fc67b1ef3255e9d9c5f4c38fd6a8fb266995974beec982728ca1
4
+ data.tar.gz: 297a013726a6e9ff9e81b385a8074e45afd5e9a22739e28852219f35cd1ad3a4
5
5
  SHA512:
6
- metadata.gz: d5fe08eb23fa452e06432cb8b72cb101b257b854fb33ae71455fd8c2a0d81356704f66fd11af330392783aa6c6a5b4730df1884718459c5d4acfe3abc74b8b48
7
- data.tar.gz: b2d8251fc66c8d47085bf6338457975b6f29e707a173c8e107d5755137bd850ead86c6d130b6410b29e4155e87ae4c67cac3739278f0ddd4a7826fc94ffba53e
6
+ metadata.gz: 6f171865bebc41146db262fcbdf5f05bdd3daf0e424cf29eaf26351408ca5d35db904057dfd64f0b57bd6a18190e48596bfd2af21ebfbf75d79a7fbc421c2bd1
7
+ data.tar.gz: d69f48dcc19e090e4b7c0fa880f142d9a76ff95e1aa6d8f73803558902c66621abbc7f28d24ca683ecadab7a5f6855fb88913dfecf05e5699a12780e98722ff7
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parsanol
4
+ module Atoms
5
+ # PARG runtime-state atoms (parsanol-ruby#129): per-parse mutable
6
+ # slots living in the parse context's capture store, so capture
7
+ # rollback, scope discipline and the dispatch semantics are the
8
+ # proven dynamic-callback ones. All four are pure data — they
9
+ # serialize as structured tags and rebuild identically in any
10
+ # runtime that implements the state machine.
11
+ class StateBase < Base
12
+ def cached?
13
+ false
14
+ end
15
+
16
+ private
17
+
18
+ # Slots and captures may live in a parent scope frame (an earlier
19
+ # element's frame); Scope#[] only reads the active one.
20
+ def lookup(context, key)
21
+ context.captures.fetch(key)
22
+ rescue Parsanol::Scope::UndefinedVariable
23
+ nil
24
+ end
25
+
26
+ def mark_unsafe(context)
27
+ # The atom reads mutable parse state (captures), so no enclosing
28
+ # composite result may be memoized — replaying it would skip the
29
+ # re-evaluation (same contract as Atoms::Dynamic).
30
+ context.mark_cache_unsafe!
31
+ end
32
+ end
33
+
34
+ # Writes a state slot. Two forms: a literal value (always succeeds,
35
+ # consumes nothing) or an inline expression — the expression matches
36
+ # at the position and the slot stores its consumed text (the
37
+ # block_open pattern: match the delimiter, remember it).
38
+ class StateSet < StateBase
39
+ def initialize(slot, value: nil, atom: nil)
40
+ super()
41
+ @slot = slot
42
+ @value = value
43
+ @atom = atom
44
+ end
45
+
46
+ attr_reader :slot, :value, :atom
47
+
48
+ def try(source, context, _consume_all)
49
+ mark_unsafe(context)
50
+ if @atom.nil?
51
+ context.captures[@slot] = @value
52
+ return [true, ""]
53
+ end
54
+
55
+ start = source.bytepos
56
+ outcome = @atom.apply(source, context, false)
57
+ return outcome unless outcome.first
58
+
59
+ span = source.input.byteslice(start, source.bytepos - start)
60
+ context.captures[@slot] = span
61
+ outcome
62
+ end
63
+
64
+ def to_s_inner(_prec)
65
+ "set #{@slot}=#{@value || '…'}"
66
+ end
67
+ end
68
+
69
+ # Matches the state slot's current value verbatim at the position —
70
+ # the block-delimiter comparison ("exactly what was captured above").
71
+ class StateMatch < StateBase
72
+ def initialize(slot)
73
+ super()
74
+ @slot = slot
75
+ end
76
+
77
+ attr_reader :slot
78
+
79
+ def try(source, context, _consume_all)
80
+ mark_unsafe(context)
81
+ expected = lookup(context, @slot)
82
+ return [false, nil] if expected.nil?
83
+
84
+ actual = source.peek(expected.length)
85
+ return [false, nil] if actual != expected
86
+
87
+ source.bytepos += expected.length
88
+ [true, actual]
89
+ end
90
+
91
+ def to_s_inner(_prec)
92
+ "state(#{@slot})"
93
+ end
94
+ end
95
+
96
+ # Dispatches on the slot's current value to a rule reference. The
97
+ # resolver maps a rule name to its atom lazily (rules may still be
98
+ # under construction when this atom is built). A nil arm fails the
99
+ # atom — the native dynamic-callback parity.
100
+ class StateSwitch < StateBase
101
+ def initialize(slot, arms, default, resolver)
102
+ super()
103
+ @slot = slot
104
+ @arms = arms
105
+ @default = default
106
+ @resolver = resolver
107
+ end
108
+
109
+ attr_reader :slot, :arms, :default
110
+
111
+ def try(source, context, consume_all)
112
+ mark_unsafe(context)
113
+ current = lookup(context, @slot)
114
+ rule_name = @arms[current] || @default
115
+ return [false, nil] if rule_name.nil?
116
+
117
+ @resolver.call(rule_name).apply(source, context, consume_all)
118
+ end
119
+
120
+ def to_s_inner(_prec)
121
+ "switch(#{@slot})"
122
+ end
123
+ end
124
+
125
+ # A foreign atom: a class resolved from the artifact's customs
126
+ # binding at compile time. The instance runs as-is on the Ruby
127
+ # engine; the serialized tag carries the binding so any runtime can
128
+ # decide support loudly.
129
+ class CustomRef < Base
130
+ def initialize(name, klass)
131
+ super()
132
+ @name = name
133
+ @atom = klass.new
134
+ end
135
+
136
+ attr_reader :name
137
+
138
+ def cached?
139
+ false
140
+ end
141
+
142
+ def try(source, context, consume_all)
143
+ @atom.try(source, context, consume_all)
144
+ end
145
+
146
+ def to_s_inner(_prec)
147
+ "custom(#{@name})"
148
+ end
149
+ end
150
+ end
151
+ end
@@ -38,6 +38,7 @@ module Parsanol
38
38
  require "parsanol/atoms/entity"
39
39
  require "parsanol/atoms/capture"
40
40
  require "parsanol/atoms/dynamic"
41
+ require "parsanol/atoms/state"
41
42
  require "parsanol/atoms/scope"
42
43
  require "parsanol/atoms/infix"
43
44
  # Load visitor pattern (must be after all atom classes)
@@ -68,6 +68,14 @@ module Parsanol
68
68
  serialize_capture(atom)
69
69
  when Parsanol::Atoms::Scope
70
70
  serialize_scope(atom)
71
+ when Parsanol::Atoms::StateSet
72
+ serialize_state_set(atom)
73
+ when Parsanol::Atoms::StateMatch
74
+ serialize_state_match(atom)
75
+ when Parsanol::Atoms::StateSwitch
76
+ serialize_state_switch(atom)
77
+ when Parsanol::Atoms::CustomRef
78
+ serialize_custom_ref(atom)
71
79
  when Parsanol::Atoms::Dynamic
72
80
  serialize_dynamic(atom)
73
81
  when Parsanol::Atoms::Ignored
@@ -219,6 +227,34 @@ module Parsanol
219
227
  }
220
228
  end
221
229
 
230
+ def serialize_state_set(atom)
231
+ payload = { "slot" => atom.slot.to_s }
232
+ if atom.atom
233
+ payload["expr"] = serialize_atom(atom.atom)
234
+ else
235
+ payload["value"] = atom.value
236
+ end
237
+ { "StateSet" => payload }
238
+ end
239
+
240
+ def serialize_state_match(atom)
241
+ { "StateMatch" => { "slot" => atom.slot.to_s } }
242
+ end
243
+
244
+ def serialize_state_switch(atom)
245
+ {
246
+ "StateSwitch" => {
247
+ "slot" => atom.slot.to_s,
248
+ "arms" => atom.arms.transform_keys(&:to_s),
249
+ "default" => atom.default,
250
+ },
251
+ }
252
+ end
253
+
254
+ def serialize_custom_ref(atom)
255
+ { "CustomRef" => { "name" => atom.name } }
256
+ end
257
+
222
258
  def serialize_dynamic(atom)
223
259
  # Dynamic evaluates a Ruby block at parse time.
224
260
  # Register the block and get a callback ID for FFI.
@@ -57,10 +57,15 @@ module Parsanol
57
57
  # path is a straight register-and-run — no Ruby recompilation. The
58
58
  # Ruby atom runtime (mode: :ruby) remains available explicitly, and
59
59
  # serves as the fallback on platforms without the extension.
60
+ #
61
+ # Dynamic artifacts (runtime state, custom atoms — #129) route to
62
+ # the Ruby engine: the envelope carries the PARG source precisely
63
+ # so a Ruby runtime can rebuild the live atom tree; the closures
64
+ # are not portable JSON.
60
65
  def parse(entry_name, input, mode: :native)
61
66
  case mode
62
67
  when :native
63
- if Native.available?
68
+ if !envelope["dynamic"] && Native.available?
64
69
  return Native.parse(JSON.generate(entry(entry_name).fetch("grammar")), input)
65
70
  end
66
71
 
@@ -50,6 +50,7 @@ module Parsanol
50
50
  @atom_cache = {}
51
51
  @first_cache = {}.compare_by_identity
52
52
  @ref_stack = []
53
+ @dynamic = false
53
54
  @tables = {}
54
55
  @warnings = []
55
56
  end
@@ -67,6 +68,16 @@ module Parsanol
67
68
  Result.new(@atom_cache, @warnings, envelope)
68
69
  end
69
70
 
71
+ def resolve_custom(name)
72
+ class_name = @document.customs.fetch(name)
73
+ klass = Object.const_get(class_name)
74
+ Atoms::CustomRef.new(name, klass)
75
+ rescue NameError
76
+ raise CompileError,
77
+ "custom atom #{name.inspect} cannot resolve #{class_name.inspect} " \
78
+ "(bind the class before compiling, or fix the custom declaration)"
79
+ end
80
+
70
81
  def atom_for(name)
71
82
  @atom_cache[name] ||= build_atom(fetch_rule(name))
72
83
  end
@@ -157,6 +168,13 @@ module Parsanol
157
168
  end
158
169
 
159
170
  def fetch_rule(name)
171
+ # Customs are opaque terminals for the analysis passes (first
172
+ # sets, left-recursion): a literal stand-in keeps them
173
+ # non-nullable with a singleton first set.
174
+ if !@document.rules.key?(name) && @document.customs.key?(name)
175
+ return Node.new(:lit, "\u0001", false)
176
+ end
177
+
160
178
  @document.rules[name] || raise(CompileError, "unknown rule #{name.inspect}")
161
179
  end
162
180
 
@@ -194,7 +212,31 @@ module Parsanol
194
212
  end
195
213
 
196
214
  Atoms::Named.new(build_atom(node.b), node.a.to_sym)
197
- when :ref then Atoms::Entity.new(node.a) { atom_for(node.a) }
215
+ when :set
216
+ @dynamic = true
217
+ slot = node.a.to_sym
218
+ value = node.b
219
+ if value.is_a?(String)
220
+ Atoms::StateSet.new(slot, value: value)
221
+ else
222
+ Atoms::StateSet.new(slot, atom: build_atom(value))
223
+ end
224
+ when :state_match
225
+ @dynamic = true
226
+ Atoms::StateMatch.new(node.a.to_sym)
227
+ when :switch
228
+ @dynamic = true
229
+ slot = node.a.to_sym
230
+ arms = node.b.to_h { |match, rule| [match, rule] }
231
+ default = arms.delete(:default)
232
+ Atoms::StateSwitch.new(slot, arms, default, method(:atom_for))
233
+ when :ref
234
+ if @document.customs.key?(node.a)
235
+ @dynamic = true
236
+ resolve_custom(node.a)
237
+ else
238
+ Atoms::Entity.new(node.a) { atom_for(node.a) }
239
+ end
198
240
  when :table
199
241
  values = table_column(node.a, node.b)
200
242
  Atoms::Alternative.new(*values.map { |value| Atoms::Str.new(value) })
@@ -430,6 +472,9 @@ module Parsanol
430
472
  "docs" => @document.docs,
431
473
  "source" => @document.source,
432
474
  }
475
+ envelope["customs"] = @document.customs unless @document.customs.empty?
476
+ envelope["states"] = @document.states unless @document.states.empty?
477
+ envelope["dynamic"] = true if @dynamic
433
478
  envelope["checksum"] = Compiler.checksum(envelope)
434
479
  envelope
435
480
  end
@@ -10,7 +10,8 @@ module Parsanol
10
10
  Test = Struct.new(:entry, :kind, :input, :expect)
11
11
 
12
12
  attr_accessor :grammar_name, :version, :source, :uses
13
- attr_reader :rules, :bindings, :preprocess, :entries, :docs, :tests, :render, :derive
13
+ attr_reader :rules, :bindings, :preprocess, :entries, :docs, :tests, :render, :derive,
14
+ :customs, :states
14
15
  # Entries authored in this file (pre-import); nil-entry tests resolve
15
16
  # against these first.
16
17
  attr_accessor :own_entries
@@ -29,6 +30,8 @@ module Parsanol
29
30
  @tests = []
30
31
  @uses = []
31
32
  @own_entries = nil
33
+ @customs = {}
34
+ @states = {}
32
35
  end
33
36
 
34
37
  def validate!
@@ -18,8 +18,9 @@ module Parsanol
18
18
 
19
19
  IN_BLOCK_SECTIONS = %w[entry bindings preprocess test].freeze
20
20
 
21
+ STATE_KEYWORDS = %w[custom state].freeze
21
22
  KEYWORDS = %w[grammar as alt from_table column bindings render derive
22
- preprocess entry table_lookup].freeze
23
+ preprocess entry table_lookup custom state set switch].freeze
23
24
 
24
25
  def initialize(text)
25
26
  @tokens = Lexer.new(text).tokens
@@ -160,6 +161,19 @@ module Parsanol
160
161
  skip_newlines
161
162
  next
162
163
  end
164
+ if peek&.type == :ident && STATE_KEYWORDS.include?(peek.value)
165
+ keyword = advance.value
166
+ slot = ident.value
167
+ if keyword == "custom"
168
+ punct("=")
169
+ document.customs[slot] = unquote(expect(:str).value)
170
+ else
171
+ punct(":")
172
+ document.states[slot] = ident.value
173
+ end
174
+ skip_newlines
175
+ next
176
+ end
163
177
  name = rule_name
164
178
  document.docs[name] = docs.join("\n") unless docs.empty?
165
179
  punct("=")
@@ -274,6 +288,43 @@ module Parsanol
274
288
 
275
289
  def parse_ident(token)
276
290
  case token.value
291
+ when "state"
292
+ Node.new(:state_match, ident.value)
293
+ when "set"
294
+ slot = ident.value
295
+ punct("=")
296
+ # A string writes the literal; a bare name copies the matched
297
+ # text of a capture (the block-delimiter pattern: set the slot
298
+ # from what the opening delimiter matched).
299
+ value = if peek&.type == :str
300
+ unquote(expect(:str).value)
301
+ else
302
+ punct("(")
303
+ inner = parse_choice
304
+ punct(")")
305
+ inner
306
+ end
307
+ Node.new(:set, slot, value)
308
+ when "switch"
309
+ slot = ident.value
310
+ punct("{")
311
+ skip_newlines
312
+ arms = []
313
+ until peek&.type == :punct && peek.value == "}"
314
+ match = if peek&.type == :ident && peek.value == "_"
315
+ advance
316
+ :default
317
+ else
318
+ unquote(expect(:str).value)
319
+ end
320
+ arrow = advance
321
+ raise ParseError, "expected \"->\", got #{arrow&.type}:#{arrow&.value}" unless arrow&.value == "->"
322
+
323
+ arms << [match, ident.value]
324
+ skip_newlines
325
+ end
326
+ punct("}")
327
+ Node.new(:switch, slot, arms)
277
328
  when "alt"
278
329
  ident("from_table")
279
330
  table = unquote(expect(:str).value)
@@ -238,6 +238,11 @@ module Parsanol
238
238
  # The degradation warning prints once per reason per process — not
239
239
  # per parser instance and not per parse.
240
240
  def native_expressible?
241
+ # The ext attaches its methods lazily (available? triggers the load);
242
+ # without this guard a first-touch call NoMethodErrors inside the
243
+ # serializer (Dynamic.register -> Native.register_callback).
244
+ return false unless Parsanol::Native.available?
245
+
241
246
  Parsanol::Native::Parser.grammar_handle(root)
242
247
  true
243
248
  rescue Parsanol::Native::UnsupportedGrammar => e
@@ -84,6 +84,14 @@ module Parsanol
84
84
  @active_frame.fetch(key)
85
85
  end
86
86
 
87
+ # Chain-aware read: the active frame, then its parents ([] is
88
+ # deliberately active-frame-only for capture-scope discipline; state
89
+ # atoms read across frames — a slot may have been written inside an
90
+ # earlier sibling element's frame).
91
+ def fetch(key)
92
+ @active_frame.fetch(key)
93
+ end
94
+
87
95
  # Chain-aware membership test, mirroring Parslet's captures.key?.
88
96
  # Checks the current frame and all parents without raising.
89
97
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.63"
4
+ VERSION = "1.3.65"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -536,11 +536,19 @@ module Parsanol
536
536
  if tables.all? && pairwise_disjoint?(tables)
537
537
  dispatch_idx = emit(BYTE_DISPATCH, nil, nil, nil)
538
538
  starts = []
539
+ # A dispatched branch that matched must not fall through into
540
+ # the next branch's code: the tables are lead-byte disjoint,
541
+ # but the remaining input can still satisfy a later branch's
542
+ # terminals (pubid: " - " — the '-' branch ran the ' ' branch's
543
+ # STR on the byte after the dash). Jump every branch but the
544
+ # last past the dispatch.
545
+ jumps = []
539
546
  idx = 0
540
547
  while idx < count
541
548
  starts << flat(@ops.size)
542
549
  return nil unless compile_atom(alts[idx], consume_all)
543
550
 
551
+ jumps << emit(JMP, nil, nil, nil) unless idx == count - 1
544
552
  idx += 1
545
553
  end
546
554
  table = Array.new(256, -1)
@@ -555,6 +563,7 @@ module Parsanol
555
563
  bi2
556
564
  end
557
565
  patch(dispatch_idx, 1, table)
566
+ jumps.each { |j| patch(j, 1, flat(@ops.size)) }
558
567
  return self
559
568
  end
560
569
 
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.63
4
+ version: 1.3.65
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -70,6 +70,7 @@ files:
70
70
  - lib/parsanol/atoms/repetition.rb
71
71
  - lib/parsanol/atoms/scope.rb
72
72
  - lib/parsanol/atoms/sequence.rb
73
+ - lib/parsanol/atoms/state.rb
73
74
  - lib/parsanol/atoms/str.rb
74
75
  - lib/parsanol/atoms/visitor.rb
75
76
  - lib/parsanol/buffer.rb