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.
@@ -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
@@ -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
- | (?<cistr>"(?:[^"\\]|\\.)*")
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
- @rules[name] = @rules[name] ? "#{@rules[name]} / #{body}" : body
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
- while peek&.[](0) == :punct && peek[1] == "/"
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
- while continuation?
156
- parts << parse_repetition
157
- end
158
- parts.join(" ")
159
- end
160
-
161
- def continuation?
162
- skip_inline_ws
163
- token = peek
164
- return false if token.nil?
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
- element_start?(token)
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
- skip_inline_ws
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
- skip_inline_ws
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
- skip_inline_ws
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
- escaped = body.gsub("\\", "\\\\").gsub('"', '\\"')
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:",