parsanol 1.3.109-aarch64-linux → 1.3.112-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c17015cb92f4e965c312eb69e7bb14e212700d6660650061ccfb0bb59da35cde
4
- data.tar.gz: d246068f5c2b4b6bfe56aac5da14e450b29a8b39f36836ea5da65ab2d15a56af
3
+ metadata.gz: e622ed0c55dbbcc4295a1b3740c36e0dc725a89251eb36c7799d6b60dbb026ff
4
+ data.tar.gz: d6d4c119f0e08a8b167faae5f1bb8203cf1f18d77d9cebd132a8bed6c2817882
5
5
  SHA512:
6
- metadata.gz: fe6458c0af76600f87a58c24c00405e0148dfd50aa75b694761f1c2a88a480ed9a8113419f65eaf8407dcdcfe5ae2e4b920df995018ad8d6440d45962619d4fb
7
- data.tar.gz: e14f4c3c3bc2ed0decdae5d6339b6d06c7978f7e75e767a4f8a2cc48ffb93ae461792660eae5d81e6b26d60ddb6c248cda296834cc63acb4de98a13462150a04
6
+ metadata.gz: 6db2994d50d7d9e77a5a9b6614d3b0b461a623d06372bace9d30e9c45e09f6f55aa4efffbaa411ff00462e93e1f8b4681edc9229a15b391034293875a319846b
7
+ data.tar.gz: 4a36caf756fb3e162bdbe24235f1005a1a56e322677e86e2606900441994dbe6107ca8ce4822d02314bdda6bc38fb246dbd9767f1e816566daea4482e0ea558f
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.2/parsanol_native.so
3
- size: 42420800 bytes
4
- sha256: 81dd5dcbde9d4188c86c316102dabdb522048fe218134d89180db1334f5d2e77
3
+ size: 45892256 bytes
4
+ sha256: baf93c649b2dc899fc6b6a9e42d2f83ba44c08738db11fbb3ce74c1788507e55
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.3/parsanol_native.so
3
- size: 42655808 bytes
4
- sha256: 3c87af92ff773b9d78c286d55c8c82c7fd11d1331e0a09ca9942c28fc184ac47
3
+ size: 46127304 bytes
4
+ sha256: 542480a12619696edd8e73bbb30d995b2ed2bae78f660050ea5f3cbcbc39f94c
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.4/parsanol_native.so
3
- size: 42651368 bytes
4
- sha256: 4bc273e8b1473a31c24ef9b882ee53b001a337c620467000fe4021954bb640eb
3
+ size: 46122944 bytes
4
+ sha256: d01871833b692c8d59f1d88b7c69792d1c44ed14fa9d62180534cabf508eb440
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/4.0/parsanol_native.so
3
- size: 42661424 bytes
4
- sha256: 6c9c533b9b53407c3ef7c2c789c610fe35e860a470d7406dad4629be3f94cbf5
3
+ size: 46132984 bytes
4
+ sha256: a3d424e1bdd7e31770de8e830d3531df4010f7cb1ef5d9e10a9da04d1ac935b2
@@ -195,8 +195,19 @@ module Parsanol
195
195
 
196
196
  unless success
197
197
  last_failure = value
198
+ if ENV["PARSANOL_REP_TRACE"]
199
+ warn "REP body FAIL at iter=#{occurrence} start=#{iteration_start} pos_after=#{source.bytepos} restoring"
200
+ end
201
+ # A failed iteration may have consumed input before its own
202
+ # inner failure (an ordered choice's partial alternative);
203
+ # the repetition owns the rollback — the rep ends at the
204
+ # last SUCCESSFUL iteration's end (ruby#357).
205
+ source.bytepos = iteration_start
198
206
  break
199
207
  end
208
+ if ENV["PARSANOL_REP_TRACE"]
209
+ warn "REP body OK at iter=#{occurrence} start=#{iteration_start} end=#{source.bytepos}"
210
+ end
200
211
 
201
212
  occurrence += 1
202
213
  result[occurrence] = value
@@ -276,8 +287,19 @@ module Parsanol
276
287
 
277
288
  unless success
278
289
  last_failure = value
290
+ if ENV["PARSANOL_REP_TRACE"]
291
+ warn "REP body FAIL at iter=#{occurrence} start=#{iteration_start} pos_after=#{source.bytepos} restoring"
292
+ end
293
+ # A failed iteration may have consumed input before its own
294
+ # inner failure (an ordered choice's partial alternative);
295
+ # the repetition owns the rollback — the rep ends at the
296
+ # last SUCCESSFUL iteration's end (ruby#357).
297
+ source.bytepos = iteration_start
279
298
  break
280
299
  end
300
+ if ENV["PARSANOL_REP_TRACE"]
301
+ warn "REP body OK at iter=#{occurrence} start=#{iteration_start} end=#{source.bytepos}"
302
+ end
281
303
 
282
304
  occurrence += 1
283
305
  result[occurrence] = value
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/native/libparsanol.so
3
- size: 36759888 bytes
4
- sha256: 885d841e5628d9fae0b43e78bd28ef5f27195ca0f3b543d35aaba7e8167ef2f4
3
+ size: 41651720 bytes
4
+ sha256: cff3505da15196c090a20db6db228680d94b0467a1daa0d28ebc1e22f2066145
@@ -26,6 +26,10 @@ module Parsanol
26
26
 
27
27
  SUPPORTED_SHAPE = "parsanol-tree/v2"
28
28
 
29
+ # The editions this runtime parses (ruby#357's consolidation):
30
+ # v1 = the legacy shapes; v2 = named trees + explicit spans.
31
+ SUPPORTED_EDITIONS = %w[v1 v2].freeze
32
+
29
33
  def initialize(envelope, path, tables_dir, imports_dir: nil)
30
34
  unless envelope["shape"] == SUPPORTED_SHAPE
31
35
  raise ArtifactError,
@@ -38,6 +42,18 @@ module Parsanol
38
42
  bindings = envelope["bindings"]
39
43
  validate_bindings!(bindings) unless bindings.nil?
40
44
  @envelope = envelope
45
+ # ruby#353: absent field = the pre-1.3.109 semantics. A level
46
+ # above this runtime's is a hard error (the trees would carry
47
+ # nodes this engine never emits); a lower one warns — parsing
48
+ # works, but frozen tree comparisons will differ.
49
+ # The declared edition must be one this runtime understands
50
+ # (ruby#357's consolidation: one axis, `edition`, values v1/v2).
51
+ edition = envelope["edition"] || "v1"
52
+ unless SUPPORTED_EDITIONS.include?(edition)
53
+ raise ArtifactError,
54
+ "artifact declares edition #{edition.inspect}; this " \
55
+ "runtime supports #{SUPPORTED_EDITIONS.map(&:inspect).join(', ')}"
56
+ end
41
57
  @path = path
42
58
  @tables_dir = tables_dir
43
59
  # parsanol-ruby#208: `use`-importing grammars need the imported
@@ -233,13 +249,27 @@ module Parsanol
233
249
  # initial parse's (the native FFI's coarse failure position
234
250
  # loses the deepest offset).
235
251
  captures, reparse_error = recovery_journal(entry_name, input, mode)
236
- partial = recovery_partial(entry_name)
252
+ # The spine boundary, not the failure offset, is where the
253
+ # resync scan starts (rs#304 convergence): junk frequently lies
254
+ # BEFORE the deepest failure (the failed iteration consumed its
255
+ # own prefix), and the scan from the failure offset can never
256
+ # match it. When no iteration matched, the boundary is 0 —
257
+ # recovery still runs (rs#304 Gap B: a declared pattern is
258
+ # usable from the start).
259
+ stash = Parsanol::Recovery.take_partial
260
+ partial = recovery_partial(entry_name, stash)
261
+ boundary = stash&.first || 0
237
262
  resyncs = []
238
- if partial && reparse_error
239
- items = partial.values.first
240
- extended, resyncs = resync_extension(entry_name, input,
241
- reparse_error["offset"])
242
- items.concat(extended) if extended
263
+ if reparse_error
264
+ root_name = entry(entry_name)["root"].to_sym
265
+ items = partial&.values&.first
266
+ created_partial = items.nil?
267
+ items ||= []
268
+ extended, resyncs = resync_extension(entry_name, input, boundary)
269
+ items.concat(extended)
270
+ # Recovered iterations with no prior partial form the partial
271
+ # (the rs engine's stash exists from offset 0).
272
+ partial = { root_name => items } if created_partial && !items.empty?
243
273
  end
244
274
  { "ok" => false,
245
275
  "error" => reparse_error || recovery_error(e),
@@ -367,8 +397,7 @@ module Parsanol
367
397
  # R1.5 (TODO.sota/1): the deepest repetition prefix the failed
368
398
  # journaling pass matched, shaped like the root (the item array
369
399
  # under the root rule's name). Nil when nothing matched.
370
- def recovery_partial(entry_name)
371
- stash = Parsanol::Recovery.take_partial
400
+ def recovery_partial(entry_name, stash = Parsanol::Recovery.take_partial)
372
401
  return nil if stash.nil?
373
402
 
374
403
  _end_pos, items, _tag = stash
@@ -64,22 +64,213 @@ module Parsanol
64
64
  end
65
65
  end
66
66
 
67
+ # The bound model's shape: a nested type tree derived from the
68
+ # binding paths (ruby#308 v2). Dot segments nest objects, a
69
+ # `[]` segment is an array of objects (or of leaf values when
70
+ # the array is itself the leaf), and the leaf carries the
71
+ # binding's type and card (`0..1` = nullable, `*` cards arrive
72
+ # through the array node itself).
73
+ #
74
+ # shape({ "items[].n" => { "type" => "integer", "card" => "1" } })
75
+ # # => { "items" => { "kind" => "array",
76
+ # # "of" => { "n" => { "kind" => "leaf", "type" => "integer", "card" => "1" } } } }
77
+ def shape(fields)
78
+ root = {}
79
+ fields.each do |path, requirements|
80
+ segments = path.split(".")
81
+ node = root
82
+ segments.each_with_index do |segment, index|
83
+ array = segment.end_with?("[]")
84
+ name = array ? segment.delete_suffix("[]") : segment
85
+ leaf = index == segments.length - 1
86
+ if array && leaf
87
+ node[name] = {
88
+ "kind" => "array",
89
+ "of" => { "kind" => "leaf", "type" => requirements["type"], "card" => requirements["card"] },
90
+ }
91
+ break
92
+ end
93
+ slot = (node[name] ||= array ? { "kind" => "array", "of" => {} } : {})
94
+ node = array ? slot["of"] : slot
95
+ next unless leaf
96
+
97
+ slot.update("kind" => "leaf", "type" => requirements["type"], "card" => requirements["card"])
98
+ end
99
+ end
100
+ root
101
+ end
102
+
67
103
  # TypeScript materialization types: one interface per entry, one
68
- # property per bound field. Cards ending in * become arrays;
104
+ # interface per nested object node (v2: the interfaces mirror
105
+ # the bound model's shape). Cards ending in * become arrays;
69
106
  # 0..1 becomes nullable.
70
107
  def to_typescript(schema)
71
108
  lines = ["// Generated by parsanol parg schema --ts — binding-requirements types."]
72
109
  schema.each do |entry, data|
73
110
  lines << ""
74
- lines << "export interface #{ts_name(entry)} {"
75
- data["fields"].each do |path, requirements|
76
- lines << " #{ts_prop(path)}: #{ts_type(requirements)};"
111
+ emit_ts_interface(lines, ts_name(entry), shape(data["fields"]))
112
+ end
113
+ "#{lines.join("\n")}\n"
114
+ end
115
+
116
+ def emit_ts_interface(lines, name, node)
117
+ nested = []
118
+ lines << "export interface #{name} {"
119
+ node.each do |prop, child|
120
+ case child["kind"]
121
+ when "array"
122
+ element = child["of"]
123
+ inner = element["kind"] == "leaf" ? ts_leaf(element) : ts_ref_name(name, prop)
124
+ lines << " #{ts_prop(prop)}: #{inner}[];"
125
+ nested << [ts_ref_name(name, prop), element] unless element["kind"] == "leaf"
126
+ when "leaf"
127
+ lines << " #{ts_prop(prop)}: #{ts_leaf(child)};"
77
128
  end
78
- lines << "}"
129
+ end
130
+ lines << "}"
131
+ lines << ""
132
+ nested.each { |inner, element| emit_ts_interface(lines, inner, element) }
133
+ end
134
+
135
+ def ts_leaf(leaf)
136
+ base = ts_base(leaf["type"])
137
+ if leaf["card"].to_s.end_with?("*")
138
+ "#{base}[]"
139
+ elsif leaf["card"] == "0..1"
140
+ "#{base} | null"
141
+ else
142
+ base
143
+ end
144
+ end
145
+
146
+ def ts_base(type)
147
+ case type
148
+ when "integer", "float" then "number"
149
+ when "boolean" then "boolean"
150
+ else "string"
151
+ end
152
+ end
153
+
154
+ def ts_ref_name(parent, prop)
155
+ "#{parent}#{ts_prop(prop).split('.').map(&:capitalize).join}"
156
+ end
157
+
158
+ # Ruby materialization models: lutaml-model classes (the
159
+ # established serialization framework — the generated file
160
+ # requires it; the consumer's bundle carries the gem).
161
+ def to_ruby(schema)
162
+ lines = [
163
+ "# frozen_string_literal: true",
164
+ "",
165
+ "# Generated by parsanol parg schema --ruby — lutaml-model binding types.",
166
+ "require \"lutaml/model\"",
167
+ "",
168
+ ]
169
+ schema.each do |entry, data|
170
+ emit_ruby_class(lines, ts_name(entry), shape(data["fields"]))
79
171
  end
80
172
  "#{lines.join("\n")}\n"
81
173
  end
82
174
 
175
+ def emit_ruby_class(lines, name, node)
176
+ nested = []
177
+ node.each do |prop, child|
178
+ next unless child["kind"] == "array" && child["of"]["kind"] != "leaf"
179
+
180
+ inner = lutaml_ref_name(name, prop)
181
+ nested << [inner, child["of"]]
182
+ end
183
+ # Ruby resolves the constant at class-body time: the nested
184
+ # models precede their parents.
185
+ nested.each { |inner, element| emit_ruby_class(lines, inner, element) }
186
+ lines << "class #{name} < Lutaml::Model::Serializable"
187
+ lines <<
188
+ node.each do |prop, child|
189
+ attr_name = ts_prop(prop)
190
+ case child["kind"]
191
+ when "array"
192
+ element = child["of"]
193
+ if element["kind"] == "leaf"
194
+ lines << " attribute :#{attr_name}, #{lutaml_type(element)}, collection: true"
195
+ else
196
+ inner = lutaml_ref_name(name, prop)
197
+ lines << " attribute :#{attr_name}, #{inner}, collection: true"
198
+ nested << [inner, element]
199
+ end
200
+ when "leaf"
201
+ # lutaml-model attributes are nilable unless validated:
202
+ # a 0..1 card is the plain attribute; presence-validated
203
+ # cards are the consumer's policy, not the schema's.
204
+ lines << " attribute :#{attr_name}, #{lutaml_type(child)}"
205
+ end
206
+ end
207
+ lines << "end"
208
+ lines << ""
209
+ end
210
+
211
+ def lutaml_type(leaf)
212
+ case leaf["type"]
213
+ when "integer" then ":integer"
214
+ when "float" then ":float"
215
+ when "boolean" then ":boolean"
216
+ else ":string"
217
+ end
218
+ end
219
+
220
+ def lutaml_ref_name(parent, prop)
221
+ "#{parent}#{ts_prop(prop).split('.').map(&:capitalize).join}"
222
+ end
223
+
224
+ # Rust materialization structs (serde): one struct per nested
225
+ # object node, i64/f64/bool/String leaves, Vec for arrays,
226
+ # Option for 0..1.
227
+ def to_rust(schema)
228
+ lines = [
229
+ "// Generated by parsanol parg schema --rust — serde binding types.",
230
+ "",
231
+ ]
232
+ schema.each do |entry, data|
233
+ emit_rust_struct(lines, ts_name(entry), shape(data["fields"]))
234
+ end
235
+ "#{lines.join("\n")}\n"
236
+ end
237
+
238
+ def emit_rust_struct(lines, name, node)
239
+ nested = []
240
+ lines << "#[derive(Debug, serde::Serialize, serde::Deserialize)]"
241
+ lines << "pub struct #{name} {"
242
+ node.each do |prop, child|
243
+ field = ts_prop(prop)
244
+ snake = field.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
245
+ case child["kind"]
246
+ when "array"
247
+ element = child["of"]
248
+ inner = element["kind"] == "leaf" ? rust_leaf(element) : rust_ref_name(name, prop)
249
+ lines << " pub #{snake}: Vec<#{inner}>,"
250
+ nested << [rust_ref_name(name, prop), element] unless element["kind"] == "leaf"
251
+ when "leaf"
252
+ base = rust_leaf(child)
253
+ lines << " pub #{snake}: #{child['card'] == '0..1' ? "Option<#{base}>" : base},"
254
+ end
255
+ end
256
+ lines << "}"
257
+ lines << ""
258
+ nested.each { |inner, element| emit_rust_struct(lines, inner, element) }
259
+ end
260
+
261
+ def rust_leaf(leaf)
262
+ case leaf["type"]
263
+ when "integer" then "i64"
264
+ when "float" then "f64"
265
+ when "boolean" then "bool"
266
+ else "String"
267
+ end
268
+ end
269
+
270
+ def rust_ref_name(parent, prop)
271
+ ts_ref_name(parent, prop)
272
+ end
273
+
83
274
  def ts_name(entry)
84
275
  entry.split(/[_.]/).map(&:capitalize).join
85
276
  end
@@ -116,6 +116,7 @@ module Parsanol
116
116
  # upgrade suggestions on top.
117
117
  def lint
118
118
  source = @argv.shift
119
+ @captures_radar = @argv.delete("--captures") ? true : false
119
120
  tables = @tables_dir || default_tables_dir(source)
120
121
  document = Parsanol::PARG::Parser.new(PARG.read_utf8(source)).parse
121
122
  Parsanol::PARG::Imports.merge!(document, [File.dirname(source)])
@@ -131,6 +132,10 @@ module Parsanol
131
132
  Lints::CoreRuleDuplicates.new.warnings(document, compiler)
132
133
  .each { |upgrade| puts "upgrade: #{upgrade}" }
133
134
  end
135
+ if @captures_radar
136
+ # The migration radar (ruby#304): what modern mode would name.
137
+ compiler.modern_radar.each { |line| puts "captures: #{line}" }
138
+ end
134
139
  0
135
140
  rescue Parsanol::PARG::ParseError, Parsanol::PARG::Error => e
136
141
  warn "#{e.class}: #{e.message}"
@@ -205,10 +210,16 @@ module Parsanol
205
210
 
206
211
  def schema
207
212
  ts = @argv.delete("--ts")
213
+ ruby = @argv.delete("--ruby")
214
+ rust = @argv.delete("--rust")
208
215
  artifact = artifact_for(@argv.shift)
209
216
  schema = Parsanol::PARG::Schema.from_artifact(artifact)
210
217
  if ts
211
218
  puts Parsanol::PARG::Schema.to_typescript(schema)
219
+ elsif ruby
220
+ puts Parsanol::PARG::Schema.to_ruby(schema)
221
+ elsif rust
222
+ puts Parsanol::PARG::Schema.to_rust(schema)
212
223
  else
213
224
  puts JSON.pretty_generate(schema)
214
225
  end
@@ -228,6 +228,144 @@ module Parsanol
228
228
  @resync_cache[rule.to_sym] ||= build_atom(pattern)
229
229
  end
230
230
 
231
+ # Modern mode (ruby#304, the #315 spec): every rule reference
232
+ # that produces no capture is wrapped in Named(rule_name) at
233
+ # compile time — the tree shape consumers read, opt-in via
234
+ # `mode` in the grammar header. Engines see ordinary
235
+ # Named atoms; identity rides the differential corpus.
236
+ def apply_modern_captures(atom)
237
+ return atom if @document.edition == "v1"
238
+
239
+ wrap_uncaptured(atom)
240
+ end
241
+
242
+ def wrap_uncaptured(atom)
243
+ case atom
244
+ when Atoms::Named
245
+ atom
246
+ when Atoms::Entity
247
+ # A rule that can only match whitespace is glue, not a
248
+ # semantic capture (ruby#352): wrapping it named every gap
249
+ # in the tree and broke consumers that fold captures into
250
+ # parameters. Conservatively: the rule's body is literals
251
+ # (through sequence/alternation/repetition composition) and
252
+ # every literal strips to empty.
253
+ # rubocop:disable Lint/DuplicateBranch -- trivia-glue and
254
+ # capture-producing refs both stay unwrapped; the bodies
255
+ # agree by design.
256
+ if trivia_only?(atom.rule_name.to_s, { atom.rule_name.to_s => true }) ||
257
+ produces_capture?(atom.parslet)
258
+ atom
259
+ else
260
+ Atoms::Named.new(atom, atom.rule_name.to_s)
261
+ end
262
+ # rubocop:enable Lint/DuplicateBranch
263
+ when Atoms::Sequence, Atoms::Alternative
264
+ # rubocop:disable Lint/DuplicateBranch -- the two composites
265
+ # rebuild with their own constructors; identical bodies,
266
+ # different decisions
267
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
268
+ atom.class.new(*kids.map { |child| wrap_uncaptured(child) })
269
+ when Atoms::Repetition
270
+ Atoms::Repetition.new(wrap_uncaptured(atom.parslet), atom.min, atom.max, atom.result_tag)
271
+ else
272
+ atom
273
+ end
274
+ end
275
+
276
+ # Whether the named rule's body is provably whitespace-only:
277
+ # literals strip to empty; sequence/alternation/repetition
278
+ # compose the verdict; references resolve through the atom cache
279
+ # with a cycle set; anything else (classes, customs, authored
280
+ # captures) disqualifies.
281
+ def trivia_only?(name, seen)
282
+ return false unless @document.rules.key?(name)
283
+
284
+ trivia_walk(atom_for(name), seen)
285
+ end
286
+
287
+ def trivia_walk(atom, seen)
288
+ case atom
289
+ when Atoms::Str
290
+ atom.str.to_s.strip.empty?
291
+ when Atoms::Sequence, Atoms::Alternative
292
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
293
+ kids.all? { |child| trivia_walk(child, seen) }
294
+ when Atoms::Repetition
295
+ trivia_walk(atom.parslet, seen)
296
+ when Atoms::Entity
297
+ name = atom.rule_name.to_s
298
+ return false if seen[name]
299
+
300
+ seen[name] = true
301
+ trivia_only?(name, seen)
302
+ else
303
+ false
304
+ end
305
+ end
306
+
307
+ # Whether the atom's flattening surfaces any Named capture:
308
+ # rule references resolve through (their own wrap pass already
309
+ # ran — a wrapped ref produces a capture), trivia never
310
+ # surfaces, everything else recurses structurally.
311
+ def produces_capture?(atom, seen = [])
312
+ return false if seen.include?(atom.object_id)
313
+
314
+ seen << atom.object_id
315
+ case atom
316
+ when Atoms::Named then true
317
+ when Atoms::Entity
318
+ wrapped = atom.parslet.is_a?(Atoms::Named)
319
+ wrapped || produces_capture?(atom.parslet, seen)
320
+ when Atoms::Sequence, Atoms::Alternative
321
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
322
+ kids.any? { |child| produces_capture?(child, seen) }
323
+ when Atoms::Repetition
324
+ produces_capture?(atom.parslet, seen)
325
+ else
326
+ false
327
+ end
328
+ end
329
+
330
+ # The migration radar (ruby#304 `parg lint --captures`): for a
331
+ # LEGACY grammar, every rule reference modern mode would wrap —
332
+ # reference site, target rule. The compiler's state is warm
333
+ # (all rules built) when the lint calls this.
334
+ def modern_radar
335
+ # The radar is the classic dialect's migration tool: on a v2
336
+ # grammar every reference is already named.
337
+ return [] if @document.edition != "v1"
338
+
339
+ lines = []
340
+ @document.rules.each_key do |name|
341
+ atom = @atom_cache[name]
342
+ next unless atom
343
+
344
+ radar_walk(atom, name, lines, [])
345
+ end
346
+ lines
347
+ end
348
+
349
+ def radar_walk(atom, site, lines, seen)
350
+ return if seen.include?(atom.object_id)
351
+
352
+ seen << atom.object_id
353
+ case atom
354
+ when Atoms::Named then nil
355
+ when Atoms::Entity
356
+ unless produces_capture?(atom.parslet)
357
+ lines << "rule #{site}: reference to #{atom.rule_name} produces no capture -- " \
358
+ "modern mode would name it #{atom.rule_name}"
359
+ end
360
+ radar_walk(atom.parslet, site, lines, seen)
361
+ when Atoms::Sequence, Atoms::Alternative
362
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
363
+ kids.each { |child| radar_walk(child, site, lines, seen) }
364
+ when Atoms::Repetition
365
+ radar_walk(atom.parslet, site, lines, seen)
366
+ end
367
+ end
368
+
231
369
  def atom_for(name)
232
370
  inject_core_rules!
233
371
  expand_left_recursion! unless @expanded
@@ -247,6 +385,15 @@ module Parsanol
247
385
  atom = Atoms::Sequence.new(skip_maybe_atom, atom, skip_maybe_atom)
248
386
  end
249
387
  end
388
+ # The modern wrap runs LAST — the guide's spec table: trivia
389
+ # atoms are in place first, so the pass sees the final tree.
390
+ # The unwrapped tree lands in the cache BEFORE the wrap (the
391
+ # wrap's capture probes resolve rule references through
392
+ # atom_for; a mutual-reference grammar must hit the cache,
393
+ # not rebuild), then the cache takes the wrapped tree.
394
+ @atom_cache[name] = atom
395
+ atom = apply_modern_captures(atom)
396
+ @atom_cache[name] = atom
250
397
  atom
251
398
  end
252
399
  end
@@ -1077,8 +1224,22 @@ module Parsanol
1077
1224
  # back to a reference implementation instead of failing obscurely
1078
1225
  # at parse time. Checksummed like the rest of the envelope.
1079
1226
  "source_hash" => Digest::SHA256.hexdigest(@document.source.to_s),
1227
+ # The U3 provenance stamp (rs#297): which Unicode table source
1228
+ # the compiling engine's categories compiled against. An
1229
+ # engine-specific stamp (checksummed here, excluded from the
1230
+ # cross-engine parity set like compiler_version); the
1231
+ # observable behavior stays pinned by the unicode corpus
1232
+ # fixtures.
1233
+ "unicode_tables" => "onigmo (Ruby #{RUBY_VERSION})",
1080
1234
  "compiler_version" => Parsanol::VERSION,
1081
1235
  }
1236
+ # ruby#304: the envelope always names the capture dialect —
1237
+ # consumers read the tree-shape contract before parsing.
1238
+ envelope["edition"] = @document.edition
1239
+ # ruby#353: the language-semantics level the trees were frozen
1240
+ # against (1.3.108's implicit-capture shapes = 1; the
1241
+ # explicit end_guard/marker/hyphen nodes = 2). Consumers freeze
1242
+ # tree shapes and mappers; the loader asserts the level at
1082
1243
  if @document.skip_declaration
1083
1244
  # parsanol-ruby#134: declared trivia rule. Injection is baked
1084
1245
  # into the compiled trees (plain atom kinds), so consumers
@@ -17,7 +17,7 @@ module Parsanol
17
17
 
18
18
  Test = Struct.new(:entry, :kind, :input, :expect)
19
19
 
20
- attr_accessor :grammar_name, :version, :source, :uses, :skip_declaration, :skip_source,
20
+ attr_accessor :grammar_name, :version, :edition, :source, :uses, :skip_declaration, :skip_source,
21
21
  :atomic_rules, :skip_capture_key, :brace_rules
22
22
  # R2 declared resync: rule name -> skip pattern (the span matched
23
23
  # at a recovery point is consumed as annotated error text).
@@ -35,6 +35,11 @@ module Parsanol
35
35
 
36
36
  def initialize
37
37
  @grammar_name = nil
38
+ # The default capture dialect is v1 (the owner's reversal,
39
+ # 2026-10-28): runtime compiles of unpinned sources keep the
40
+ # legacy shapes — the pubid fleet froze against them. mode "v2"
41
+ # stays the named-trees opt-in via the grammar header.
42
+ @edition = "v1"
38
43
  @version = "0.0.0"
39
44
  @source = nil
40
45
  @rules = {}
@@ -19,9 +19,15 @@ module Parsanol
19
19
  TARGETS = %i[xgrammar].freeze
20
20
 
21
21
  class << self
22
- def call(artifact_json, target: :xgrammar)
22
+ # `recovery: true` lowers each declared `resync <rule> = <pattern>`
23
+ # as an escape alternative at the repetition spine: the target's
24
+ # matcher then accepts exactly the strings the engines' recovery
25
+ # path accepts — clean parses, and violate-then-resync parses
26
+ # where the junk is emitted deliberately and the grammar closes
27
+ # back over the continuation (ruby#321's partial-mask story).
28
+ def call(artifact_json, target: :xgrammar, recovery: false)
23
29
  case target
24
- when :xgrammar then Xgrammar.new(artifact_json).call
30
+ when :xgrammar then Xgrammar.new(artifact_json, recovery: recovery).call
25
31
  else raise Error, "unknown export target #{target.inspect} (supported: #{TARGETS.join(', ')})"
26
32
  end
27
33
  end
@@ -34,20 +40,64 @@ module Parsanol
34
40
  # equality. The artifact wire has no rule boundaries (all atoms
35
41
  # inline), so each entry lowers as one expression.
36
42
  class Xgrammar
37
- def initialize(artifact_json)
43
+ def initialize(artifact_json, recovery: false)
38
44
  @envelope = JSON.parse(artifact_json)
45
+ @recovery = recovery
39
46
  end
40
47
 
41
48
  def call
42
49
  rules = @envelope["entries"].map do |entry, meta|
43
50
  grammar = meta.fetch("grammar")
44
- "#{entry}_entry ::= #{lower(grammar.fetch('atoms'), grammar.fetch('root'))}"
51
+ "#{entry}_entry ::= #{lower_entry(entry, grammar)}"
45
52
  end
46
53
  "#{rules.join("\n")}\n"
47
54
  end
48
55
 
49
56
  private
50
57
 
58
+ # The recovery wrap mirrors the engines' resync contract: the
59
+ # root must be a repetition spine (the same shape resync
60
+ # recovery itself requires), and each declared resync pattern
61
+ # becomes an alternative of the spine body — junk between
62
+ # complete iterations is exactly what the recovery path
63
+ # consumes and resumes. Inferred (undeclared) policies are the
64
+ # trial scan and have no closed form here: the plain grammar
65
+ # ships, and the differential reports the gap honestly.
66
+ def lower_entry(entry, grammar)
67
+ atoms = grammar.fetch("atoms")
68
+ body = lower(atoms, grammar.fetch("root"))
69
+ return body unless @recovery
70
+
71
+ pattern = @envelope.dig("entries", entry, "resync")
72
+ return body unless pattern
73
+
74
+ spine = repetition_spine(atoms, grammar.fetch("root"))
75
+ unless spine
76
+ unsupported("recovery export: the entry root is not a repetition spine")
77
+ end
78
+ junk = lower(pattern.fetch("atoms"), pattern.fetch("root"))
79
+ # A spine item is a body, optionally preceded by one junk
80
+ # span: the engines scan forward to the first pattern match,
81
+ # consume it as one annotated span, and resume with the next
82
+ # iteration — which may be absent (trailing junk ends the
83
+ # parse, recovered).
84
+ "( #{body} | ( #{junk} ) ( #{body} )? )*"
85
+ end
86
+
87
+ # The lowerable body of the spine's repetition: Named/Entity
88
+ # unwrapping down to the Repetition atom, unbounded (max nil).
89
+ # Nil when the root is not that shape.
90
+ def repetition_spine(atoms, index)
91
+ tag, fields = atoms.fetch(index).first
92
+ case tag
93
+ when "Named", "Entity" then repetition_spine(atoms, fields.fetch("atom"))
94
+ when "Repetition"
95
+ return nil unless fields["max"].nil?
96
+
97
+ fields.fetch("atom")
98
+ end
99
+ end
100
+
51
101
  def lower(atoms, index)
52
102
  tag, fields = atoms.fetch(index).first
53
103
  case tag
@@ -91,7 +141,15 @@ module Parsanol
91
141
  # which IS the simple-case-fold semantics for ASCII. A non-ASCII
92
142
  # fold group needs a fold table the target does not have.
93
143
  def re_class(pattern)
94
- return pattern if pattern.start_with?("[") && pattern.end_with?("]")
144
+ # Categories have no EBNF-class spelling: they must be
145
+ # rejected naming the construct, never emitted raw (the
146
+ # target's lexer rejects them, which fails the round trip
147
+ # obscurely instead of at export time).
148
+ if pattern.include?("\\p{")
149
+ unsupported("unicode category #{pattern.inspect}")
150
+ end
151
+
152
+ return de_escape_class(pattern) if pattern.start_with?("[") && pattern.end_with?("]")
95
153
 
96
154
  if (folded = pattern[/\A\(\?i:(.*)\)\z/m, 1])
97
155
  return expand_fold(folded, pattern)
@@ -100,6 +158,16 @@ module Parsanol
100
158
  unsupported("regex pattern #{pattern.inspect}")
101
159
  end
102
160
 
161
+ # Class endpoints \u00ff / \u{10ffff} spell as the literal
162
+ # characters — XGrammar's EBNF classes are UTF-8 text and
163
+ # accept them; emitting the escapes raw fails the target's
164
+ # lexer instead.
165
+ def de_escape_class(pattern)
166
+ pattern
167
+ .gsub(/\\u\{([0-9a-fA-F]+)\}/) { [$1.to_i(16)].pack("U") }
168
+ .gsub(/\\u([0-9a-fA-F]{4})/) { [$1.to_i(16)].pack("U") }
169
+ end
170
+
103
171
  def expand_fold(text, pattern)
104
172
  unsupported("non-ASCII fold group #{pattern.inspect} -- the target has no fold table") unless text.ascii_only?
105
173
 
@@ -231,6 +231,23 @@ module Parsanol
231
231
  document.grammar_name = ident.value
232
232
  ident("version")
233
233
  document.version = unquote(expect(:str).value)
234
+ # `edition` — the language edition (ruby#304, ruby#357's
235
+ # consolidation; Rust's Cargo.toml `edition` is the model):
236
+ # the author declares, per grammar, which tree semantics the
237
+ # grammar means. THE DEFAULT IS "v1" (the legacy shapes — the
238
+ # owner's 2026-10-28 reversal). "v2" = fully named trees +
239
+ # explicit spans. `mode` remains accepted as a deprecated
240
+ # alias for the keyword. The edition is canonicalized here —
241
+ # everything downstream only ever sees "v1"/"v2".
242
+ if peek&.type == :ident && %w[edition mode].include?(peek.value)
243
+ keyword = advance.value
244
+ edition = unquote(expect(:str).value)
245
+ unless %w[v2 v1 classic].include?(edition)
246
+ raise ParseError,
247
+ "unknown edition #{edition.inspect} -- supported editions are \"v1\" (default) and \"v2\" (named trees)"
248
+ end
249
+ document.edition = edition == "v2" ? "v2" : "v1"
250
+ end
234
251
  punct("{")
235
252
  skip_newlines
236
253
  until peek&.type == :punct && peek.value == "}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.109"
4
+ VERSION = "1.3.112"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -971,6 +971,50 @@ module Parsanol
971
971
  @memoize = memoize
972
972
  end
973
973
 
974
+ # The RE match's [char width, byte advance]: single-class
975
+ # patterns match one character; multi-character patterns (%i
976
+ # literals reach RE with no byte table) consume their full
977
+ # length (ruby#357: advancing one character skipped the
978
+ # pattern's remainder and let the parse re-match inside it).
979
+ def re_match_width(scanner, input, pos, table, pattern)
980
+ if table
981
+ w = CHAR_WIDTH[input.byteslice(pos, 1).bytes[0]]
982
+ w = char_width_slow(input, pos) if w.zero?
983
+ [w, w]
984
+ else
985
+ len = scanner.skip(pattern)
986
+ [input.byteslice(pos, len).length, len]
987
+ end
988
+ end
989
+
990
+ # An RE instruction's match at pos: [matched?, char width, byte
991
+ # advance]. Single-class patterns match one character;
992
+ # multi-character patterns (%i literals reach RE with no byte
993
+ # table) consume their FULL length (ruby#357: advancing one
994
+ # character skipped the pattern's remainder and let the parse
995
+ # re-match inside it).
996
+ def re_scan(scanner, input, pos, table, pattern)
997
+ b = table && input.getbyte(pos)
998
+ matched =
999
+ if b && b < 128
1000
+ table[b]
1001
+ elsif pos < input.length
1002
+ (scanner.pos = pos) && scanner.match?(pattern)
1003
+ else
1004
+ false
1005
+ end
1006
+ return [false, 0, 0] unless matched
1007
+
1008
+ if b && b < 128
1009
+ w = CHAR_WIDTH[b]
1010
+ w = char_width_slow(input, pos) if w.zero?
1011
+ [true, w, w]
1012
+ else
1013
+ len = scanner.skip(pattern)
1014
+ [true, input.byteslice(pos, len).length, len]
1015
+ end
1016
+ end
1017
+
974
1018
  def execute # rubocop:disable Metrics/MethodLength, Metrics/BlockLength, Metrics/BlockNesting -- single dispatch loop; hot path
975
1019
  ops = @ops
976
1020
  input = @input
@@ -1054,20 +1098,11 @@ module Parsanol
1054
1098
  pc = fail_pc
1055
1099
  end
1056
1100
  when RE
1057
- b = bytes[pos]
1058
- matched =
1059
- if (table = ops[pc + 2]) && b && b < 128
1060
- table[b]
1061
- elsif pos < n
1062
- (scanner.pos = pos) && scanner.match?(ops[pc + 1])
1063
- else
1064
- false
1065
- end
1101
+ table = ops[pc + 2]
1102
+ matched, w, advance = re_scan(scanner, input, pos, table, ops[pc + 1])
1066
1103
  if matched
1067
- w = CHAR_WIDTH[b]
1068
- w = char_width_slow(input, pos) if w.zero?
1069
1104
  rstack << ((pos << 20) | w)
1070
- pos += w
1105
+ pos += advance
1071
1106
  pc += 4
1072
1107
  else
1073
1108
  pc = fail_pc
@@ -1257,19 +1292,10 @@ module Parsanol
1257
1292
  kc = kids[ki + 3]
1258
1293
  case kop
1259
1294
  when RE
1260
- b = bytes[pos]
1261
- m = if kb && b && b < 128
1262
- kb[b]
1263
- elsif pos < n
1264
- (scanner.pos = pos) && scanner.match?(ka)
1265
- else
1266
- false
1267
- end
1268
- if m
1269
- w = CHAR_WIDTH[b]
1270
- w = char_width_slow(input, pos) if w.zero?
1295
+ matched, w, advance = re_scan(scanner, input, pos, kb, ka)
1296
+ if matched
1271
1297
  values << ((pos << 20) | w)
1272
- pos += w
1298
+ pos += advance
1273
1299
  else
1274
1300
  failed = true
1275
1301
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.109
4
+ version: 1.3.112
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-25 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A small Ruby library for constructing parsers in the PEG (Parsing Expression
14
14
  Grammar) fashion. Parsanol provides Parslet-compatible API with additional features