parsanol 1.3.109 → 1.3.113

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: 7abef89c341c194089d17849bb5e877e4f45a5b2f20a73e347b29905e49a377a
4
- data.tar.gz: 4e3619755881576b2ea58c74b8d95ec70ee736cc0dfb258a511e44f83f73a2cf
3
+ metadata.gz: d9a04b4f0add7d25933bf9ddd041f28cf067e9b2750abb6595915d63cdd4cd3a
4
+ data.tar.gz: 69767361cdcb52ddeb7e0dbd4b05498e72fffe4d64639c4c5d00aad723f7c659
5
5
  SHA512:
6
- metadata.gz: 3f128038206f01fe80038c2f5c02f44fd2202155e322386bd810b6e4f04cd1f08dc7c545cedc69270ec6f2d5eb539ef8a2561d6ff5837092bfa5cecffb2a81dc
7
- data.tar.gz: bca7e6ef0d98b589d08973a0f20c198d0d864f394cf511b69fc5195f35c90bfd924455acd1a27a0558788723df864cc9b96c51a8d226e33854748d84f396d72a
6
+ metadata.gz: 8407c77dbca38833e2e2ddf189d601be84ed14f07bbe5e972feb76ce016df3f88c7725951ed20ba74b5403ad83dae25fef8d3f1259c6312e6e4158e709c037ba
7
+ data.tar.gz: 9fe4fd4df2ba548a686226ee50c80c60945dfad0e3719d7b1593fe9678a5a2f464df833940cb87201e86b98243c025d94ab42664b914f29ea3ca2649773c6aff
data/Cargo.lock CHANGED
@@ -307,34 +307,16 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
307
307
 
308
308
  [[package]]
309
309
  name = "parsanol"
310
- version = "0.18.21"
310
+ version = "0.19.7"
311
311
  source = "registry+https://github.com/rust-lang/crates.io-index"
312
- checksum = "6b6762cda50ce912cb3b247e97559a4da27cec3843103a132568251952fbe9d0"
313
- dependencies = [
314
- "ahash",
315
- "getrandom 0.3.4",
316
- "getrandom 0.4.3",
317
- "hashbrown",
318
- "parsanol-derive 0.18.21",
319
- "regex",
320
- "regex-syntax",
321
- "serde",
322
- "serde_json",
323
- "sha2",
324
- ]
325
-
326
- [[package]]
327
- name = "parsanol"
328
- version = "0.19.0"
329
- source = "registry+https://github.com/rust-lang/crates.io-index"
330
- checksum = "366ad171b2117387a355e5a09fb0dfaa0a2177ca7536d9b3a0b18ae63cca3594"
312
+ checksum = "5233088cf0cde65b9a6e5e41d558b689becc722ccf156946c8f118049f2d3f5c"
331
313
  dependencies = [
332
314
  "ahash",
333
315
  "getrandom 0.3.4",
334
316
  "getrandom 0.4.3",
335
317
  "hashbrown",
336
318
  "magnus",
337
- "parsanol-derive 0.19.0",
319
+ "parsanol-derive",
338
320
  "regex",
339
321
  "regex-syntax",
340
322
  "serde",
@@ -344,20 +326,9 @@ dependencies = [
344
326
 
345
327
  [[package]]
346
328
  name = "parsanol-derive"
347
- version = "0.18.21"
348
- source = "registry+https://github.com/rust-lang/crates.io-index"
349
- checksum = "77eed76fe86c54c683fbb8e2b462495506e9bc2f3007beec82bf6d90b8a82209"
350
- dependencies = [
351
- "proc-macro2",
352
- "quote",
353
- "syn 3.0.6",
354
- ]
355
-
356
- [[package]]
357
- name = "parsanol-derive"
358
- version = "0.19.0"
329
+ version = "0.19.7"
359
330
  source = "registry+https://github.com/rust-lang/crates.io-index"
360
- checksum = "271be27b8391e4c5731af22bd9294536592a2731e258566caa4b7045d09edf3d"
331
+ checksum = "c84688541fbbd11509183b9c53debd924bcdbfae8cf8aca053414ea11a7aa7e9"
361
332
  dependencies = [
362
333
  "proc-macro2",
363
334
  "quote",
@@ -368,7 +339,7 @@ dependencies = [
368
339
  name = "parsanol_cdylib"
369
340
  version = "1.3.52"
370
341
  dependencies = [
371
- "parsanol 0.18.21",
342
+ "parsanol",
372
343
  ]
373
344
 
374
345
  [[package]]
@@ -377,7 +348,7 @@ version = "1.0.2"
377
348
  dependencies = [
378
349
  "log",
379
350
  "magnus",
380
- "parsanol 0.19.0",
351
+ "parsanol",
381
352
  "rb-sys",
382
353
  ]
383
354
 
@@ -13,4 +13,4 @@ doc = false
13
13
 
14
14
  [dependencies]
15
15
  # 0.17.0 carries LookSpec + the widened opaque writeback
16
- parsanol = { version = "0.18.0" }
16
+ parsanol = { version = "0.19.1" }
@@ -32,7 +32,7 @@ magnus = { version = "0.9" }
32
32
  # parsanol parser library (crates.io; 0.17.0 carries the LookSpec regex
33
33
  # lookbehind (rs#163 gap 1) and the widened opaque writeback: the
34
34
  # lazy-continuation fix, parsanol-ruby#160)
35
- parsanol = { version = "0.19.0", features = ["ruby"] }
35
+ parsanol = { version = "0.19.1", features = ["ruby"] }
36
36
 
37
37
  # Logging
38
38
  log = "0.4"
@@ -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
@@ -26,6 +26,18 @@ module Parsanol
26
26
 
27
27
  SUPPORTED_SHAPE = "parsanol-tree/v2"
28
28
 
29
+ # The editions this runtime parses (ruby#357's consolidation):
30
+ # 1 = the legacy shapes; 2 = named trees + explicit spans.
31
+ SUPPORTED_EDITIONS = [1, 2].freeze
32
+
33
+ # The header and envelope spellings that have shipped: the quoted
34
+ # v-forms stay loadable so artifacts baked before the numeric
35
+ # surface remain readable.
36
+ EDITION_ALIASES = {
37
+ 1 => 1, "1" => 1, "v1" => 1, "classic" => 1,
38
+ 2 => 2, "2" => 2, "v2" => 2
39
+ }.freeze
40
+
29
41
  def initialize(envelope, path, tables_dir, imports_dir: nil)
30
42
  unless envelope["shape"] == SUPPORTED_SHAPE
31
43
  raise ArtifactError,
@@ -38,6 +50,15 @@ module Parsanol
38
50
  bindings = envelope["bindings"]
39
51
  validate_bindings!(bindings) unless bindings.nil?
40
52
  @envelope = envelope
53
+ # The declared edition must be one this runtime understands
54
+ # (ruby#357's consolidation: one axis, `edition`). Absent field
55
+ # = the pre-1.3.109 artifacts, whose trees are edition 1.
56
+ edition = EDITION_ALIASES[envelope.fetch("edition", 1)]
57
+ unless SUPPORTED_EDITIONS.include?(edition)
58
+ raise ArtifactError,
59
+ "artifact declares edition #{envelope['edition'].inspect}; this " \
60
+ "runtime supports #{SUPPORTED_EDITIONS.map(&:inspect).join(', ')}"
61
+ end
41
62
  @path = path
42
63
  @tables_dir = tables_dir
43
64
  # parsanol-ruby#208: `use`-importing grammars need the imported
@@ -50,6 +71,13 @@ module Parsanol
50
71
 
51
72
  def version = envelope["version"]
52
73
 
74
+ # The edition the artifact's trees carry: 1 = the legacy shapes,
75
+ # 2 = fully named trees. Legacy artifacts that spell the field
76
+ # "v2" (or omit it) normalize here.
77
+ def edition
78
+ EDITION_ALIASES.fetch(envelope.fetch("edition", 1))
79
+ end
80
+
53
81
  # Freshness (parsanol-ruby#209): what source revision and compiler
54
82
  # produced this artifact. `source_hash` is the SHA-256 of the .parg
55
83
  # text at compile time; nil on artifacts baked before the field
@@ -233,13 +261,27 @@ module Parsanol
233
261
  # initial parse's (the native FFI's coarse failure position
234
262
  # loses the deepest offset).
235
263
  captures, reparse_error = recovery_journal(entry_name, input, mode)
236
- partial = recovery_partial(entry_name)
264
+ # The spine boundary, not the failure offset, is where the
265
+ # resync scan starts (rs#304 convergence): junk frequently lies
266
+ # BEFORE the deepest failure (the failed iteration consumed its
267
+ # own prefix), and the scan from the failure offset can never
268
+ # match it. When no iteration matched, the boundary is 0 —
269
+ # recovery still runs (rs#304 Gap B: a declared pattern is
270
+ # usable from the start).
271
+ stash = Parsanol::Recovery.take_partial
272
+ partial = recovery_partial(entry_name, stash)
273
+ boundary = stash&.first || 0
237
274
  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
275
+ if reparse_error
276
+ root_name = entry(entry_name)["root"].to_sym
277
+ items = partial&.values&.first
278
+ created_partial = items.nil?
279
+ items ||= []
280
+ extended, resyncs = resync_extension(entry_name, input, boundary)
281
+ items.concat(extended)
282
+ # Recovered iterations with no prior partial form the partial
283
+ # (the rs engine's stash exists from offset 0).
284
+ partial = { root_name => items } if created_partial && !items.empty?
243
285
  end
244
286
  { "ok" => false,
245
287
  "error" => reparse_error || recovery_error(e),
@@ -367,8 +409,7 @@ module Parsanol
367
409
  # R1.5 (TODO.sota/1): the deepest repetition prefix the failed
368
410
  # journaling pass matched, shaped like the root (the item array
369
411
  # under the root rule's name). Nil when nothing matched.
370
- def recovery_partial(entry_name)
371
- stash = Parsanol::Recovery.take_partial
412
+ def recovery_partial(entry_name, stash = Parsanol::Recovery.take_partial)
372
413
  return nil if stash.nil?
373
414
 
374
415
  _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,234 @@ 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, character classes are whitespace-only;
278
+ # sequence/alternation/repetition compose the verdict; references
279
+ # resolve through the atom cache with a cycle set; anything else
280
+ # (customs, authored 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::Re
292
+ whitespace_class?(atom.match)
293
+ when Atoms::Sequence, Atoms::Alternative
294
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
295
+ kids.all? { |child| trivia_walk(child, seen) }
296
+ when Atoms::Repetition
297
+ trivia_walk(atom.parslet, seen)
298
+ when Atoms::Entity
299
+ name = atom.rule_name.to_s
300
+ return false if seen[name]
301
+
302
+ seen[name] = true
303
+ trivia_only?(name, seen)
304
+ else
305
+ false
306
+ end
307
+ end
308
+
309
+ # A class matches only whitespace when every codepoint it admits
310
+ # strips to empty — the same verdict Atoms::Str gets. Compiled
311
+ # classes are the dialect class_pattern emits: bracket bodies of
312
+ # literal chars, \t\n\v\f\r, \xHH, \uXXXX and \u{...}, plus ranges
313
+ # between them. Anything else disqualifies.
314
+ def whitespace_class?(pattern)
315
+ body = pattern.to_s[/\A\[(.*)\]\z/m, 1]
316
+ return false if body.nil? || body.empty? || body.start_with?("^")
317
+
318
+ ranges = class_ranges(body)
319
+ ranges&.all? { |lo, hi| whitespace_range?(lo, hi) }
320
+ end
321
+
322
+ WHITESPACE_CODEPOINTS = [0x00, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20].freeze
323
+
324
+ def whitespace_range?(low, high)
325
+ return false if low > high
326
+
327
+ union = WHITESPACE_CODEPOINTS.count { |code| code.between?(low, high) }
328
+ union == high - low + 1
329
+ end
330
+
331
+ def class_ranges(body)
332
+ slots = []
333
+ pending_range = false
334
+ index = 0
335
+ while index < body.length
336
+ if body[index] == "-" && !pending_range && !slots.empty?
337
+ pending_range = true
338
+ index += 1
339
+ next
340
+ end
341
+
342
+ code, index = class_token(body, index)
343
+ return nil unless code
344
+
345
+ if pending_range
346
+ previous = slots.pop
347
+ slots << [previous[0], code].minmax
348
+ pending_range = false
349
+ else
350
+ slots << [code, code]
351
+ end
352
+ end
353
+ return nil if pending_range
354
+
355
+ slots
356
+ end
357
+
358
+ def class_token(body, index)
359
+ char = body[index]
360
+ unless char == "\\"
361
+ return [char.ord, index + 1]
362
+ end
363
+
364
+ escaped = body[index + 1]
365
+ return nil if escaped.nil?
366
+
367
+ case escaped
368
+ when "x" then hex_token(body, index + 2, 2)
369
+ when "u"
370
+ if body[index + 2] == "{"
371
+ close = body.index("}", index + 3)
372
+ return nil unless close
373
+
374
+ [body[(index + 3)...close].to_i(16), close + 1]
375
+ else
376
+ hex_token(body, index + 2, 4)
377
+ end
378
+ when "t" then [0x09, index + 2]
379
+ when "n" then [0x0A, index + 2]
380
+ when "v" then [0x0B, index + 2]
381
+ when "f" then [0x0C, index + 2]
382
+ when "r" then [0x0D, index + 2]
383
+ else [escaped.ord, index + 2]
384
+ end
385
+ end
386
+
387
+ def hex_token(body, start, length)
388
+ hex = body[start, length]
389
+ return nil unless hex&.match?(/\A\h{#{length}}\z/)
390
+
391
+ [hex.to_i(16), start + length]
392
+ end
393
+
394
+ # Whether the atom's flattening surfaces any Named capture:
395
+ # rule references resolve through (their own wrap pass already
396
+ # ran — a wrapped ref produces a capture), trivia never
397
+ # surfaces, everything else recurses structurally.
398
+ def produces_capture?(atom, seen = [])
399
+ return false if seen.include?(atom.object_id)
400
+
401
+ seen << atom.object_id
402
+ case atom
403
+ when Atoms::Named then true
404
+ when Atoms::Entity
405
+ wrapped = atom.parslet.is_a?(Atoms::Named)
406
+ wrapped || produces_capture?(atom.parslet, seen)
407
+ when Atoms::Sequence, Atoms::Alternative
408
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
409
+ kids.any? { |child| produces_capture?(child, seen) }
410
+ when Atoms::Repetition
411
+ produces_capture?(atom.parslet, seen)
412
+ else
413
+ false
414
+ end
415
+ end
416
+
417
+ # The migration radar (ruby#304 `parg lint --captures`): for a
418
+ # LEGACY grammar, every rule reference modern mode would wrap —
419
+ # reference site, target rule. The compiler's state is warm
420
+ # (all rules built) when the lint calls this.
421
+ def modern_radar
422
+ # The radar is the classic dialect's migration tool: on a v2
423
+ # grammar every reference is already named.
424
+ return [] if @document.edition != "v1"
425
+
426
+ lines = []
427
+ @document.rules.each_key do |name|
428
+ atom = @atom_cache[name]
429
+ next unless atom
430
+
431
+ radar_walk(atom, name, lines, [])
432
+ end
433
+ lines
434
+ end
435
+
436
+ def radar_walk(atom, site, lines, seen)
437
+ return if seen.include?(atom.object_id)
438
+
439
+ seen << atom.object_id
440
+ case atom
441
+ when Atoms::Named then nil
442
+ when Atoms::Entity
443
+ # Mirror wrap_uncaptured's verdicts: trivia glue and
444
+ # capture-producing refs both stay bare.
445
+ unless trivia_only?(atom.rule_name.to_s, { atom.rule_name.to_s => true }) ||
446
+ produces_capture?(atom.parslet)
447
+ lines << "rule #{site}: reference to #{atom.rule_name} produces no capture -- " \
448
+ "modern mode would name it #{atom.rule_name}"
449
+ end
450
+ radar_walk(atom.parslet, site, lines, seen)
451
+ when Atoms::Sequence, Atoms::Alternative
452
+ kids = atom.respond_to?(:alternatives) ? atom.alternatives : atom.parslets
453
+ kids.each { |child| radar_walk(child, site, lines, seen) }
454
+ when Atoms::Repetition
455
+ radar_walk(atom.parslet, site, lines, seen)
456
+ end
457
+ end
458
+
231
459
  def atom_for(name)
232
460
  inject_core_rules!
233
461
  expand_left_recursion! unless @expanded
@@ -247,6 +475,15 @@ module Parsanol
247
475
  atom = Atoms::Sequence.new(skip_maybe_atom, atom, skip_maybe_atom)
248
476
  end
249
477
  end
478
+ # The modern wrap runs LAST — the guide's spec table: trivia
479
+ # atoms are in place first, so the pass sees the final tree.
480
+ # The unwrapped tree lands in the cache BEFORE the wrap (the
481
+ # wrap's capture probes resolve rule references through
482
+ # atom_for; a mutual-reference grammar must hit the cache,
483
+ # not rebuild), then the cache takes the wrapped tree.
484
+ @atom_cache[name] = atom
485
+ atom = apply_modern_captures(atom)
486
+ @atom_cache[name] = atom
250
487
  atom
251
488
  end
252
489
  end
@@ -1077,8 +1314,20 @@ module Parsanol
1077
1314
  # back to a reference implementation instead of failing obscurely
1078
1315
  # at parse time. Checksummed like the rest of the envelope.
1079
1316
  "source_hash" => Digest::SHA256.hexdigest(@document.source.to_s),
1317
+ # The U3 provenance stamp (rs#297): which Unicode table source
1318
+ # the compiling engine's categories compiled against. An
1319
+ # engine-specific stamp (checksummed here, excluded from the
1320
+ # cross-engine parity set like compiler_version); the
1321
+ # observable behavior stays pinned by the unicode corpus
1322
+ # fixtures.
1323
+ "unicode_tables" => "onigmo (Ruby #{RUBY_VERSION})",
1080
1324
  "compiler_version" => Parsanol::VERSION,
1081
1325
  }
1326
+ # ruby#304: the envelope always names the capture edition —
1327
+ # consumers read the tree-shape contract before parsing. The
1328
+ # numeric form is canonical: 1 = the legacy shapes, 2 = fully
1329
+ # named trees + explicit spans.
1330
+ envelope["edition"] = @document.edition == "v2" ? 2 : 1
1082
1331
  if @document.skip_declaration
1083
1332
  # parsanol-ruby#134: declared trivia rule. Injection is baked
1084
1333
  # into the compiled trees (plain atom kinds), so consumers
@@ -21,7 +21,7 @@ module Parsanol
21
21
 
22
22
  def diff(old_envelope, new_envelope)
23
23
  findings = []
24
- findings.concat(diff_mode(old_envelope, new_envelope))
24
+ findings.concat(diff_edition(old_envelope, new_envelope))
25
25
  findings.concat(diff_entries(old_envelope, new_envelope))
26
26
  findings.concat(diff_bindings(old_envelope, new_envelope))
27
27
  findings.concat(diff_tests(old_envelope, new_envelope))
@@ -168,18 +168,25 @@ module Parsanol
168
168
  branches.uniq.sort
169
169
  end
170
170
 
171
- # -- mode ---------------------------------------------------------
171
+ # -- edition ------------------------------------------------------
172
172
 
173
- def diff_mode(old_env, new_env)
174
- old_mode = old_env["mode"]
175
- new_mode = new_env["mode"]
176
- return [] if old_mode == new_mode
173
+ def diff_edition(old_env, new_env)
174
+ old_edition = edition_level(old_env)
175
+ new_edition = edition_level(new_env)
176
+ return [] if old_edition == new_edition
177
177
 
178
- [Finding.new(:major, :mode_changed,
179
- "mode changed #{old_mode.inspect} -> #{new_mode.inspect} " \
178
+ [Finding.new(:major, :edition_changed,
179
+ "edition changed #{old_edition.inspect} -> #{new_edition.inspect} " \
180
180
  "(every uncaptured node's tree shape changes)")]
181
181
  end
182
182
 
183
+ # Artifacts baked before the numeric surface spell the edition
184
+ # "v1"/"v2"; they mean the same levels, not a change.
185
+ def edition_level(envelope)
186
+ declared = envelope.fetch("edition", 1)
187
+ Artifact::EDITION_ALIASES.fetch(declared, declared)
188
+ end
189
+
183
190
  # -- bindings -----------------------------------------------------
184
191
 
185
192
  def diff_bindings(old_env, new_env)
@@ -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
 
@@ -18,6 +18,14 @@ module Parsanol
18
18
 
19
19
  IN_BLOCK_SECTIONS = %w[entry bindings preprocess test].freeze
20
20
 
21
+ # The header's edition spellings: the bare numbers are canonical
22
+ # (Rust's Cargo.toml model); the quoted forms stay accepted as
23
+ # aliases for grammars written before the numeric surface.
24
+ EDITIONS = {
25
+ "1" => "v1", "v1" => "v1", "classic" => "v1",
26
+ "2" => "v2", "v2" => "v2"
27
+ }.freeze
28
+
21
29
  STATE_KEYWORDS = %w[custom state].freeze
22
30
  KEYWORDS = %w[grammar as alt from_table column bindings render derive
23
31
  preprocess entry table_lookup custom state set switch
@@ -227,10 +235,39 @@ module Parsanol
227
235
 
228
236
  def punct(char) = expect(:punct, char)
229
237
 
238
+ def edition_value
239
+ token = advance
240
+ raw = case token&.type
241
+ when :num then token.value
242
+ when :str then unquote(token.value)
243
+ else
244
+ raise ParseError,
245
+ "edition must be a number (1 or 2) or a quoted alias " \
246
+ "(\"v1\", \"v2\", \"classic\")"
247
+ end
248
+ EDITIONS.fetch(raw) do
249
+ raise ParseError,
250
+ "unknown edition #{raw.inspect} -- supported editions are " \
251
+ "1 (default) and 2 (named trees)"
252
+ end
253
+ end
254
+
230
255
  def parse_grammar(document)
231
256
  document.grammar_name = ident.value
232
257
  ident("version")
233
258
  document.version = unquote(expect(:str).value)
259
+ # `edition` — the language edition (ruby#304, ruby#357's
260
+ # consolidation; Rust's Cargo.toml `edition` is the model):
261
+ # the author declares, per grammar, which tree semantics the
262
+ # grammar means. THE DEFAULT IS 1 (the legacy shapes — the
263
+ # owner's 2026-10-28 reversal). 2 = fully named trees +
264
+ # explicit spans. `mode` remains accepted as a deprecated
265
+ # alias for the keyword. The edition is canonicalized here —
266
+ # everything downstream only ever sees "v1"/"v2".
267
+ if peek&.type == :ident && %w[edition mode].include?(peek.value)
268
+ advance
269
+ document.edition = edition_value
270
+ end
234
271
  punct("{")
235
272
  skip_newlines
236
273
  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.113"
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,7 +1,7 @@
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.113
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.