parsanol 1.3.107-arm-linux → 1.3.109-arm-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 +4 -4
- data/lib/parsanol/3.2/parsanol_native.so +2 -2
- data/lib/parsanol/3.3/parsanol_native.so +2 -2
- data/lib/parsanol/3.4/parsanol_native.so +2 -2
- data/lib/parsanol/4.0/parsanol_native.so +2 -2
- data/lib/parsanol/native/libparsanol.so +0 -0
- data/lib/parsanol/parg/cli.rb +97 -0
- data/lib/parsanol/parg/compiler.rb +102 -5
- data/lib/parsanol/parg/diff.rb +264 -0
- data/lib/parsanol/parg/document.rb +9 -1
- data/lib/parsanol/parg/export.rb +123 -0
- data/lib/parsanol/parg/import.rb +3 -1
- data/lib/parsanol/parg/importers/abnf.rb +148 -28
- data/lib/parsanol/parg/importers/antlr.rb +407 -0
- data/lib/parsanol/parg/lexer.rb +1 -0
- data/lib/parsanol/parg/lints.rb +448 -1
- data/lib/parsanol/parg/parser.rb +59 -2
- data/lib/parsanol/parg.rb +2 -0
- data/lib/parsanol/slice.rb +13 -4
- data/lib/parsanol/version.rb +1 -1
- metadata +5 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Parsanol
|
|
4
|
+
module PARG
|
|
5
|
+
# Lowers a compiled artifact to a decoder-facing grammar form
|
|
6
|
+
# (ruby#321): XGrammar's EBNF dialect first. One artifact feeding
|
|
7
|
+
# both worlds is the point — human-facing PARG with provenance,
|
|
8
|
+
# tests, and five-engine replay on one side; the constrained
|
|
9
|
+
# decoder on the other.
|
|
10
|
+
#
|
|
11
|
+
# The supported subset lowers 1:1. Everything the target cannot
|
|
12
|
+
# express — lookaround, behind-guards, custom atoms, fold groups —
|
|
13
|
+
# is rejected NAMING the construct: the importer philosophy in
|
|
14
|
+
# reverse, never silently dropped.
|
|
15
|
+
module Export
|
|
16
|
+
class Error < StandardError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
TARGETS = %i[xgrammar].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(artifact_json, target: :xgrammar)
|
|
23
|
+
case target
|
|
24
|
+
when :xgrammar then Xgrammar.new(artifact_json).call
|
|
25
|
+
else raise Error, "unknown export target #{target.inspect} (supported: #{TARGETS.join(', ')})"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# XGrammar's EBNF: one rule per entry; literals "…"; classes
|
|
31
|
+
# […]; suffixes * + ? {m,n}. Ordered choice is lost — the target
|
|
32
|
+
# resolves ambiguity its own way; the export lowers alternatives
|
|
33
|
+
# in the same order and the round-trip corpus pins acceptance
|
|
34
|
+
# equality. The artifact wire has no rule boundaries (all atoms
|
|
35
|
+
# inline), so each entry lowers as one expression.
|
|
36
|
+
class Xgrammar
|
|
37
|
+
def initialize(artifact_json)
|
|
38
|
+
@envelope = JSON.parse(artifact_json)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
rules = @envelope["entries"].map do |entry, meta|
|
|
43
|
+
grammar = meta.fetch("grammar")
|
|
44
|
+
"#{entry}_entry ::= #{lower(grammar.fetch('atoms'), grammar.fetch('root'))}"
|
|
45
|
+
end
|
|
46
|
+
"#{rules.join("\n")}\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def lower(atoms, index)
|
|
52
|
+
tag, fields = atoms.fetch(index).first
|
|
53
|
+
case tag
|
|
54
|
+
when "Str" then "\"#{fields['pattern'].gsub('\\') { '\\\\\\\\' }.gsub('"') { '\\\\\"' }}\""
|
|
55
|
+
when "Re" then re_class(fields["pattern"])
|
|
56
|
+
when "Sequence" then fields["atoms"].map { |child| lower(atoms, child) }.join(" ")
|
|
57
|
+
when "Alternative" then "( #{fields['atoms'].map { |child| lower(atoms, child) }.join(' | ')} )"
|
|
58
|
+
when "Repetition" then suffix(lower(atoms, fields["atom"]), fields["min"], fields["max"])
|
|
59
|
+
when "Maybe" then suffix(lower(atoms, fields["atom"]), 0, 1)
|
|
60
|
+
when "Named", "Entity" then "( #{lower(atoms, fields['atom'])} )"
|
|
61
|
+
when "Lookahead" then unsupported("lookahead (!x / &x)")
|
|
62
|
+
when "BehindGuard" then unsupported("behind-guard (!<x / &<x)")
|
|
63
|
+
when "Custom" then unsupported("custom atom")
|
|
64
|
+
when "Constant" then unsupported("constant atom")
|
|
65
|
+
else unsupported("atom #{tag}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def suffix(body, min, max)
|
|
70
|
+
min ||= 0
|
|
71
|
+
if min.zero? && max.nil?
|
|
72
|
+
"#{wrap(body)}*"
|
|
73
|
+
elsif min == 1 && max.nil?
|
|
74
|
+
"#{wrap(body)}+"
|
|
75
|
+
elsif min.zero? && max == 1
|
|
76
|
+
"#{wrap(body)}?"
|
|
77
|
+
else
|
|
78
|
+
"#{wrap(body)}{#{min},#{max || ''}}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def wrap(body)
|
|
83
|
+
return body if body =~ /\A"[^"]*"\z/ || body =~ /\A\[[^\]]*\]\z/ || body =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
84
|
+
|
|
85
|
+
"( #{body} )"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A Re pattern the class form cannot carry is unsupported
|
|
89
|
+
# rather than approximated — EXCEPT the ASCII fold literal:
|
|
90
|
+
# (?i:...) expands mechanically to per-character [Xx] classes,
|
|
91
|
+
# which IS the simple-case-fold semantics for ASCII. A non-ASCII
|
|
92
|
+
# fold group needs a fold table the target does not have.
|
|
93
|
+
def re_class(pattern)
|
|
94
|
+
return pattern if pattern.start_with?("[") && pattern.end_with?("]")
|
|
95
|
+
|
|
96
|
+
if (folded = pattern[/\A\(\?i:(.*)\)\z/m, 1])
|
|
97
|
+
return expand_fold(folded, pattern)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
unsupported("regex pattern #{pattern.inspect}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def expand_fold(text, pattern)
|
|
104
|
+
unsupported("non-ASCII fold group #{pattern.inspect} -- the target has no fold table") unless text.ascii_only?
|
|
105
|
+
|
|
106
|
+
text.chars.map do |char|
|
|
107
|
+
if char =~ /[A-Za-z]/ && char.bytesize == 1
|
|
108
|
+
"[#{char.upcase}#{char.downcase}]"
|
|
109
|
+
elsif char == '"'
|
|
110
|
+
'\\"'
|
|
111
|
+
else
|
|
112
|
+
char
|
|
113
|
+
end
|
|
114
|
+
end.join
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def unsupported(what)
|
|
118
|
+
raise Error, "xgrammar export: #{what} has no XGrammar equivalent"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/parsanol/parg/import.rb
CHANGED
|
@@ -12,8 +12,9 @@ module Parsanol
|
|
|
12
12
|
autoload :Abnf, "parsanol/parg/importers/abnf"
|
|
13
13
|
autoload :Ebnf, "parsanol/parg/importers/ebnf"
|
|
14
14
|
autoload :Pest, "parsanol/parg/importers/pest"
|
|
15
|
+
autoload :Antlr, "parsanol/parg/importers/antlr"
|
|
15
16
|
|
|
16
|
-
KINDS = %i[abnf ebnf pest].freeze
|
|
17
|
+
KINDS = %i[abnf ebnf pest antlr].freeze
|
|
17
18
|
|
|
18
19
|
module_function
|
|
19
20
|
|
|
@@ -22,6 +23,7 @@ module Parsanol
|
|
|
22
23
|
when :abnf then Abnf.call(text)
|
|
23
24
|
when :ebnf then Ebnf.call(text)
|
|
24
25
|
when :pest then Pest.call(text)
|
|
26
|
+
when :antlr then Antlr.call(text)
|
|
25
27
|
else
|
|
26
28
|
raise Error,
|
|
27
29
|
"unknown import kind #{kind.inspect} (supported: #{KINDS.map(&:inspect).join(', ')})"
|
|
@@ -12,13 +12,17 @@ module Parsanol
|
|
|
12
12
|
# first-set lint flags order-dependent branches after import.
|
|
13
13
|
# - rule names are case-insensitive in ABNF -> normalized to snake_case
|
|
14
14
|
# - prose-vals (<...>) are rejected: they are not machine-parseable
|
|
15
|
+
# - quoted strings have NO escape mechanism (RFC 5234 3.4): a bare
|
|
16
|
+
# backslash is an ordinary character; a literal DQUOTE needs the
|
|
17
|
+
# DQUOTE core rule, not a backslash
|
|
15
18
|
class Abnf
|
|
16
19
|
TOKEN = /
|
|
17
20
|
(?<ws>[ \t]+)
|
|
18
21
|
| (?<comment>;[^\n]*)
|
|
19
22
|
| (?<crlf>\r?\n)
|
|
20
|
-
| (?<sstr>%s"
|
|
21
|
-
| (?<
|
|
23
|
+
| (?<sstr>%s"[^"]*")
|
|
24
|
+
| (?<istr>%i"[^"]*")
|
|
25
|
+
| (?<cistr>"[^"]*")
|
|
22
26
|
| (?<numval>%[xbdo][0-9A-Za-z]+(?:-[0-9A-Za-z]+|(?:\.[0-9A-Za-z]+)+)?)
|
|
23
27
|
| (?<prose><[^>\n]*>)
|
|
24
28
|
| (?<definedas>=\/|=)
|
|
@@ -50,6 +54,12 @@ module Parsanol
|
|
|
50
54
|
"WSP" => "%x20 / %x09",
|
|
51
55
|
}.freeze
|
|
52
56
|
|
|
57
|
+
# RFC 3986's `0<pchar>` empty idiom lowers to a captured empty
|
|
58
|
+
# literal — the compiler's documented marker form (bare ""
|
|
59
|
+
# zero-width matches are rejected). The sentinel is replaced
|
|
60
|
+
# with the rule's own capture name in parse_rule.
|
|
61
|
+
EMPTY_IDIOM = ":empty0:"
|
|
62
|
+
|
|
53
63
|
class << self
|
|
54
64
|
def call(text)
|
|
55
65
|
new(text).call
|
|
@@ -66,6 +76,7 @@ module Parsanol
|
|
|
66
76
|
|
|
67
77
|
def call
|
|
68
78
|
parse_rules
|
|
79
|
+
@nullable = nullable_rules
|
|
69
80
|
emit
|
|
70
81
|
end
|
|
71
82
|
|
|
@@ -100,6 +111,19 @@ module Parsanol
|
|
|
100
111
|
advance while peek && INLINE_WS.include?(peek[0])
|
|
101
112
|
end
|
|
102
113
|
|
|
114
|
+
# A CRLF followed by an indented line is a continuation: body
|
|
115
|
+
# whitespace, not the end of the rule (RFC 5234 section 3.4 —
|
|
116
|
+
# RFC 3986's IPv6address styles its alternations this way).
|
|
117
|
+
def skip_body_ws
|
|
118
|
+
loop do
|
|
119
|
+
skip_inline_ws
|
|
120
|
+
break unless peek&.[](0) == :crlf && peek(1)&.[](0) == :ws
|
|
121
|
+
|
|
122
|
+
advance
|
|
123
|
+
advance
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
103
127
|
def parse_rules
|
|
104
128
|
loop do
|
|
105
129
|
skip_leading
|
|
@@ -132,8 +156,13 @@ module Parsanol
|
|
|
132
156
|
end
|
|
133
157
|
|
|
134
158
|
body = parse_alternation
|
|
159
|
+
body = body.gsub(EMPTY_IDIOM) { %(("" as #{name})) }
|
|
135
160
|
if defined[1] == "=/"
|
|
136
|
-
|
|
161
|
+
merged = @rules[name] ? "#{@rules[name]} / #{body}" : body
|
|
162
|
+
# Incremental definitions may restate a branch; identical
|
|
163
|
+
# branches are one branch in ABNF's unordered semantics and
|
|
164
|
+
# a compile error in PARG's.
|
|
165
|
+
@rules[name] = split_top_level(merged, " / ").uniq.join(" / ")
|
|
137
166
|
elsif @rules.key?(name)
|
|
138
167
|
raise Error, "ABNF: duplicate rule #{name.inspect}"
|
|
139
168
|
else
|
|
@@ -143,7 +172,10 @@ module Parsanol
|
|
|
143
172
|
|
|
144
173
|
def parse_alternation
|
|
145
174
|
parts = [parse_concatenation]
|
|
146
|
-
|
|
175
|
+
loop do
|
|
176
|
+
skip_body_ws
|
|
177
|
+
break unless peek&.[](0) == :punct && peek[1] == "/"
|
|
178
|
+
|
|
147
179
|
advance
|
|
148
180
|
parts << parse_concatenation
|
|
149
181
|
end
|
|
@@ -152,27 +184,19 @@ module Parsanol
|
|
|
152
184
|
|
|
153
185
|
def parse_concatenation
|
|
154
186
|
parts = [parse_repetition]
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if token[0] == :crlf
|
|
167
|
-
unless peek(1)&.[](0) == :ws
|
|
168
|
-
return false
|
|
187
|
+
loop do
|
|
188
|
+
save = @pos
|
|
189
|
+
skip_body_ws
|
|
190
|
+
if peek && element_start?(peek)
|
|
191
|
+
parts << parse_repetition
|
|
192
|
+
else
|
|
193
|
+
# A close paren, an alternation slash, or end of rule:
|
|
194
|
+
# not ours to consume.
|
|
195
|
+
@pos = save
|
|
196
|
+
break
|
|
169
197
|
end
|
|
170
|
-
|
|
171
|
-
advance
|
|
172
|
-
advance
|
|
173
|
-
return true
|
|
174
198
|
end
|
|
175
|
-
|
|
199
|
+
parts.join(" ")
|
|
176
200
|
end
|
|
177
201
|
|
|
178
202
|
def element_start?(token)
|
|
@@ -181,7 +205,14 @@ module Parsanol
|
|
|
181
205
|
end
|
|
182
206
|
|
|
183
207
|
def parse_repetition
|
|
184
|
-
|
|
208
|
+
skip_body_ws
|
|
209
|
+
# RFC 3986's empty idiom: `0<pchar>` — zero of an unparseable
|
|
210
|
+
# prose-val is exactly the empty match.
|
|
211
|
+
if peek&.[](0) == :num && peek[1] == "0" && peek(1)&.[](0) == :prose
|
|
212
|
+
advance
|
|
213
|
+
advance
|
|
214
|
+
return EMPTY_IDIOM
|
|
215
|
+
end
|
|
185
216
|
if peek&.[](0) == :num
|
|
186
217
|
prefix = advance[1]
|
|
187
218
|
if peek&.[](0) == :punct && peek[1] == "*"
|
|
@@ -200,16 +231,19 @@ module Parsanol
|
|
|
200
231
|
end
|
|
201
232
|
|
|
202
233
|
def parse_element
|
|
203
|
-
|
|
234
|
+
skip_body_ws
|
|
204
235
|
token = advance
|
|
205
236
|
raise Error, "ABNF: unexpected end of rule body" if token.nil?
|
|
206
237
|
|
|
207
238
|
case token[0]
|
|
208
239
|
when :cistr then emit_string(token[1][1..-2], fold: true)
|
|
240
|
+
when :istr then emit_string(token[1][3..-2], fold: true)
|
|
209
241
|
when :sstr then emit_string(token[1][3..-2], fold: false)
|
|
210
242
|
when :numval then emit_numval(token[1])
|
|
211
243
|
when :name then normalize_name(token[1])
|
|
212
244
|
when :prose
|
|
245
|
+
# RFC 3986's `0<pchar>` idiom: zero occurrences of anything
|
|
246
|
+
# is the empty match. A real prose-val stays unimportable.
|
|
213
247
|
raise Error,
|
|
214
248
|
"ABNF: prose-val #{token[1].inspect} cannot be imported — " \
|
|
215
249
|
"replace it with a machine-parseable rule"
|
|
@@ -232,7 +266,7 @@ module Parsanol
|
|
|
232
266
|
end
|
|
233
267
|
|
|
234
268
|
def expect_punct(char)
|
|
235
|
-
|
|
269
|
+
skip_body_ws
|
|
236
270
|
token = advance
|
|
237
271
|
unless token && token[0] == :punct && token[1] == char
|
|
238
272
|
raise Error, "ABNF: expected #{char.inspect}"
|
|
@@ -240,7 +274,10 @@ module Parsanol
|
|
|
240
274
|
end
|
|
241
275
|
|
|
242
276
|
def emit_string(body, fold:)
|
|
243
|
-
|
|
277
|
+
# Block form: a String replacement treats backslash escapes
|
|
278
|
+
# specially, so "\\\\" would emit ONE backslash (the silent
|
|
279
|
+
# no-op this bug was).
|
|
280
|
+
escaped = body.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }
|
|
244
281
|
fold ? "%i\"#{escaped}\"" : "\"#{escaped}\""
|
|
245
282
|
end
|
|
246
283
|
|
|
@@ -267,6 +304,89 @@ module Parsanol
|
|
|
267
304
|
end
|
|
268
305
|
end
|
|
269
306
|
|
|
307
|
+
# ABNF alternation is unordered; PARG's is ordered. A branch
|
|
308
|
+
# that provably matches empty (repetitions of zero, optionals,
|
|
309
|
+
# the empty idiom) would shadow everything after it in ordered
|
|
310
|
+
# choice, so definitely-nullable branches move last — the same
|
|
311
|
+
# acceptance, no shadowing. Branches with rule references are
|
|
312
|
+
# never moved: their nullability is not provable textually.
|
|
313
|
+
def reorder_nullable_branches_last(body)
|
|
314
|
+
return body unless body.include?(" / ")
|
|
315
|
+
|
|
316
|
+
branches = split_top_level(body, " / ")
|
|
317
|
+
return body unless branches.size > 1
|
|
318
|
+
|
|
319
|
+
nullable, ordered = branches.partition { |branch| branch_nullable?(branch, @nullable) }
|
|
320
|
+
return body if nullable.empty?
|
|
321
|
+
|
|
322
|
+
(ordered + nullable).join(" / ")
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Fixpoint over the rule graph: a rule is nullable when any
|
|
326
|
+
# alternative's every item is nullable — repetitions of zero,
|
|
327
|
+
# optionals, the empty idiom, or references to nullable rules.
|
|
328
|
+
def nullable_rules
|
|
329
|
+
nullable = {}
|
|
330
|
+
changed = true
|
|
331
|
+
while changed
|
|
332
|
+
changed = false
|
|
333
|
+
@rules.each do |name, body|
|
|
334
|
+
next if nullable[name]
|
|
335
|
+
|
|
336
|
+
if split_top_level(body, " / ").any? { |branch| branch_nullable?(branch, nullable) }
|
|
337
|
+
nullable[name] = true
|
|
338
|
+
changed = true
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
nullable
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def branch_nullable?(branch, nullable)
|
|
346
|
+
items = split_top_level(branch, " ").reject(&:empty?)
|
|
347
|
+
!items.empty? && items.all? { |item| item_nullable?(item, nullable) }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def item_nullable?(item, nullable)
|
|
351
|
+
return true if item.start_with?("*")
|
|
352
|
+
return true if item.start_with?("[") && item.end_with?("]")
|
|
353
|
+
return true if item.start_with?('("" as')
|
|
354
|
+
|
|
355
|
+
name = item[/\A[a-z0-9_]+\z/]
|
|
356
|
+
name ? nullable[name] == true : false
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def split_top_level(text, separator)
|
|
360
|
+
out = []
|
|
361
|
+
depth = 0
|
|
362
|
+
current = +""
|
|
363
|
+
index = 0
|
|
364
|
+
while index < text.length
|
|
365
|
+
piece = text[index..]
|
|
366
|
+
if piece.start_with?(separator) && depth.zero?
|
|
367
|
+
out << current
|
|
368
|
+
current = +""
|
|
369
|
+
index += separator.length
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
case piece[0]
|
|
373
|
+
when "(" then depth += 1
|
|
374
|
+
when ")" then depth -= 1
|
|
375
|
+
end
|
|
376
|
+
current << piece[0]
|
|
377
|
+
index += 1
|
|
378
|
+
end
|
|
379
|
+
out << current
|
|
380
|
+
out
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def provably_empty?(branch)
|
|
384
|
+
items = split_top_level(branch, " ").reject(&:empty?)
|
|
385
|
+
!items.empty? && items.all? do |item|
|
|
386
|
+
item.start_with?("*", "[", '("" as')
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
270
390
|
def normalize_name(name)
|
|
271
391
|
name.downcase.tr("-", "_")
|
|
272
392
|
end
|
|
@@ -278,7 +398,7 @@ module Parsanol
|
|
|
278
398
|
|
|
279
399
|
" #{normalized} = #{body}"
|
|
280
400
|
end
|
|
281
|
-
rules = @rules.map { |name, body| " #{name} = #{body}" }
|
|
401
|
+
rules = @rules.map { |name, body| " #{name} = #{reorder_nullable_branches_last(body)}" }
|
|
282
402
|
lines = [
|
|
283
403
|
"# Imported from ABNF (RFC 5234/7405).",
|
|
284
404
|
"# Notes:",
|