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 +4 -4
- data/Cargo.lock +7 -36
- data/ext/parsanol_cdylib/Cargo.toml +1 -1
- data/ext/parsanol_native/Cargo.toml +1 -1
- data/lib/parsanol/atoms/repetition.rb +22 -0
- data/lib/parsanol/parg/artifact.rb +49 -8
- data/lib/parsanol/parg/authoring.rb +196 -5
- data/lib/parsanol/parg/cli.rb +11 -0
- data/lib/parsanol/parg/compiler.rb +249 -0
- data/lib/parsanol/parg/diff.rb +15 -8
- data/lib/parsanol/parg/document.rb +6 -1
- data/lib/parsanol/parg/export.rb +73 -5
- data/lib/parsanol/parg/parser.rb +37 -0
- data/lib/parsanol/version.rb +1 -1
- data/lib/parsanol/vm.rb +50 -24
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9a04b4f0add7d25933bf9ddd041f28cf067e9b2750abb6595915d63cdd4cd3a
|
|
4
|
+
data.tar.gz: 69767361cdcb52ddeb7e0dbd4b05498e72fffe4d64639c4c5d00aad723f7c659
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
310
|
+
version = "0.19.7"
|
|
311
311
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
312
|
-
checksum = "
|
|
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
|
|
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.
|
|
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 = "
|
|
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
|
|
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
|
|
351
|
+
"parsanol",
|
|
381
352
|
"rb-sys",
|
|
382
353
|
]
|
|
383
354
|
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
items
|
|
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
|
-
#
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
data/lib/parsanol/parg/cli.rb
CHANGED
|
@@ -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
|
data/lib/parsanol/parg/diff.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Parsanol
|
|
|
21
21
|
|
|
22
22
|
def diff(old_envelope, new_envelope)
|
|
23
23
|
findings = []
|
|
24
|
-
findings.concat(
|
|
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
|
-
# --
|
|
171
|
+
# -- edition ------------------------------------------------------
|
|
172
172
|
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return [] if
|
|
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, :
|
|
179
|
-
"
|
|
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 = {}
|
data/lib/parsanol/parg/export.rb
CHANGED
|
@@ -19,9 +19,15 @@ module Parsanol
|
|
|
19
19
|
TARGETS = %i[xgrammar].freeze
|
|
20
20
|
|
|
21
21
|
class << self
|
|
22
|
-
|
|
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 ::= #{
|
|
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
|
-
|
|
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
|
|
data/lib/parsanol/parg/parser.rb
CHANGED
|
@@ -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 == "}"
|
data/lib/parsanol/version.rb
CHANGED
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
1261
|
-
|
|
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 +=
|
|
1298
|
+
pos += advance
|
|
1273
1299
|
else
|
|
1274
1300
|
failed = true
|
|
1275
1301
|
end
|