parsanol 1.3.64 → 1.3.66

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: 3d3b9a74be500e1e1874c246d72614966e39ee7766731726a23d77e7b88b8f33
4
- data.tar.gz: 79d0492db122146e4bfca2ce17de4017b02d94385f8b9f6db540e7ea69ee6d02
3
+ metadata.gz: da894739a391336f9b9f65a65abc87da1fe51b3f3268c0c2334638e53f5adb8f
4
+ data.tar.gz: 6777a602a63a37e599fdc1ed9c38f82eb8b01a30a12a23c24bbed7518f37e5cf
5
5
  SHA512:
6
- metadata.gz: e7cf8b9f912837052ab62bcf35332de5b4715f0a9f43e8b9ffc8e5cb3cf5ce16a0d830c0841ad326855cad5eb8c8de8e3e9e28cdbf264f441fd40db71f78bef5
7
- data.tar.gz: 00755e792eb152ced1b9d3e38f783d1f9637cc13add763b609e8e4edaaff1dd4dafa426bf162e11e956bad14d86ad75b6eb07bd42d0015f32f48e043e72e594c
6
+ metadata.gz: 0fad1aa5febfc712a767578c4ca4d9c9c6cf3def48ed7f6fe97a66a95e45d0e628a4285660eb14b40e6d0b72bbd6422868b85ea901d84318382f23aa5744378b
7
+ data.tar.gz: 6ab9b3b4461592a3a5e91accd57d0341c59c7f878985b01fd89b73fe91c8abd71eacedd6246d0c79a2fd57547607415c0932e73cfb2c2e61b3adb2af5165a9a6
@@ -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!
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Standalone requires of this file bypass the parg autoload registry —
4
+ # declare the namespace dependency so Artifact resolves.
5
+ require "parsanol/parg"
6
+
3
7
  module Parsanol
4
8
  module PARG
5
9
  # lutaml-model integration: register a PARG artifact as a lutaml-model
@@ -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.64"
4
+ VERSION = "1.3.66"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -175,8 +175,10 @@ module Parsanol
175
175
  compiler.to_program
176
176
  end
177
177
 
178
+ # Returns violation descriptions for this atom's ordered-choice
179
+ # branches (empty-matchable non-last branches shadow later ones).
178
180
  def validate_choice_branches(atom)
179
- atom.alternatives.each_with_index do |branch, idx|
181
+ atom.alternatives.each_with_index.filter_map do |branch, idx|
180
182
  walk_branch = branch
181
183
  next if idx == atom.alternatives.size - 1
182
184
 
@@ -194,14 +196,13 @@ module Parsanol
194
196
  next if walk_branch.nil?
195
197
  next unless Compiler.new.nullable?(walk_branch)
196
198
 
197
- raise Parsanol::GrammarError,
198
- "wrong grammar: alternative branch #{idx} " \
199
- "(#{branch.inspect}) can match empty input and is not " \
200
- "the last branch — ordered choice commits to its empty " \
201
- "match, so #{atom.alternatives.size - idx - 1} later " \
202
- "branch(es) can never match at positions where it " \
203
- "matches empty. Reorder branches so empty-matchable " \
204
- "ones come last, or make the branch non-empty."
199
+ "alternative branch #{idx} (#{branch.inspect}) can match " \
200
+ "empty input and is not the last branch — ordered choice " \
201
+ "commits to its empty match, so " \
202
+ "#{atom.alternatives.size - idx - 1} later branch(es) can " \
203
+ "never match at positions where it matches empty. Reorder " \
204
+ "branches so empty-matchable ones come last, or make the " \
205
+ "branch non-empty."
205
206
  end
206
207
  end
207
208
 
@@ -211,6 +212,7 @@ module Parsanol
211
212
  # the grammar is wrong, and inputs it would mis-parse fail with a
212
213
  # baffling cause. Raise before any input is seen.
213
214
  def validate_alternatives(root)
215
+ violations = []
214
216
  visited = {}.compare_by_identity
215
217
  walk = lambda do |atom, depth|
216
218
  next if atom.nil? || depth > 20
@@ -219,26 +221,26 @@ module Parsanol
219
221
  visited[atom] = true
220
222
  case atom
221
223
  when Parsanol::Atoms::Alternative
222
- validate_choice_branches(atom)
224
+ violations.concat(validate_choice_branches(atom))
223
225
  atom.alternatives.each { |c| walk.call(c, depth + 1) }
224
226
  when Parsanol::Atoms::Sequence
225
227
  atom.parslets.each { |c| walk.call(c, depth + 1) }
226
228
  when Parsanol::Atoms::Repetition, Parsanol::Atoms::Named
227
229
  walk.call(atom.parslet, depth + 1)
228
- when Parsanol::Atoms::Lookahead
229
- walk.call(atom.bound_parslet, depth + 1)
230
- when Parsanol::Atoms::Ignored
231
- walk.call(atom.wrapped_atom, depth + 1)
232
230
  when Parsanol::Atoms::Entity
231
+ # Rule bodies hide behind the entity wrapper; without this
232
+ # descent the lint passes DSL grammars that fail the same
233
+ # grammar compiled from PARG (where bodies are inlined).
234
+ # Entities that cannot resolve outside a parse are skipped.
233
235
  begin
234
236
  walk.call(atom.parslet, depth + 1)
235
- rescue Parsanol::GrammarError
236
- raise
237
237
  rescue StandardError
238
- # Lazily resolved / recursive entity that cannot resolve
239
- # outside a parse: skip this branch of the walk.
240
238
  nil
241
239
  end
240
+ when Parsanol::Atoms::Lookahead
241
+ walk.call(atom.bound_parslet, depth + 1)
242
+ when Parsanol::Atoms::Ignored
243
+ walk.call(atom.wrapped_atom, depth + 1)
242
244
  when Parsanol::Atoms::Scope
243
245
  begin
244
246
  walk.call(atom.block.call, depth + 1)
@@ -248,6 +250,11 @@ module Parsanol
248
250
  end
249
251
  end
250
252
  walk.call(root, 0)
253
+ return if violations.empty?
254
+
255
+ raise Parsanol::GrammarError,
256
+ "wrong grammar: #{violations.size} shadowed-alternative " \
257
+ "violation(s):\n - #{violations.join('\n - ')}"
251
258
  end
252
259
 
253
260
  # Executes a compiled program. Returns [true, value] on success;
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.64
4
+ version: 1.3.66
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