parsanol 1.3.107 → 1.3.109
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 +36 -7
- data/ext/parsanol_native/Cargo.toml +1 -1
- 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 +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7abef89c341c194089d17849bb5e877e4f45a5b2f20a73e347b29905e49a377a
|
|
4
|
+
data.tar.gz: 4e3619755881576b2ea58c74b8d95ec70ee736cc0dfb258a511e44f83f73a2cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f128038206f01fe80038c2f5c02f44fd2202155e322386bd810b6e4f04cd1f08dc7c545cedc69270ec6f2d5eb539ef8a2561d6ff5837092bfa5cecffb2a81dc
|
|
7
|
+
data.tar.gz: bca7e6ef0d98b589d08973a0f20c198d0d864f394cf511b69fc5195f35c90bfd924455acd1a27a0558788723df864cc9b96c51a8d226e33854748d84f396d72a
|
data/Cargo.lock
CHANGED
|
@@ -307,16 +307,34 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
307
307
|
|
|
308
308
|
[[package]]
|
|
309
309
|
name = "parsanol"
|
|
310
|
-
version = "0.18.
|
|
310
|
+
version = "0.18.21"
|
|
311
311
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
312
|
-
checksum = "
|
|
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"
|
|
313
331
|
dependencies = [
|
|
314
332
|
"ahash",
|
|
315
333
|
"getrandom 0.3.4",
|
|
316
334
|
"getrandom 0.4.3",
|
|
317
335
|
"hashbrown",
|
|
318
336
|
"magnus",
|
|
319
|
-
"parsanol-derive",
|
|
337
|
+
"parsanol-derive 0.19.0",
|
|
320
338
|
"regex",
|
|
321
339
|
"regex-syntax",
|
|
322
340
|
"serde",
|
|
@@ -326,9 +344,20 @@ dependencies = [
|
|
|
326
344
|
|
|
327
345
|
[[package]]
|
|
328
346
|
name = "parsanol-derive"
|
|
329
|
-
version = "0.18.
|
|
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"
|
|
330
359
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
331
|
-
checksum = "
|
|
360
|
+
checksum = "271be27b8391e4c5731af22bd9294536592a2731e258566caa4b7045d09edf3d"
|
|
332
361
|
dependencies = [
|
|
333
362
|
"proc-macro2",
|
|
334
363
|
"quote",
|
|
@@ -339,7 +368,7 @@ dependencies = [
|
|
|
339
368
|
name = "parsanol_cdylib"
|
|
340
369
|
version = "1.3.52"
|
|
341
370
|
dependencies = [
|
|
342
|
-
"parsanol",
|
|
371
|
+
"parsanol 0.18.21",
|
|
343
372
|
]
|
|
344
373
|
|
|
345
374
|
[[package]]
|
|
@@ -348,7 +377,7 @@ version = "1.0.2"
|
|
|
348
377
|
dependencies = [
|
|
349
378
|
"log",
|
|
350
379
|
"magnus",
|
|
351
|
-
"parsanol",
|
|
380
|
+
"parsanol 0.19.0",
|
|
352
381
|
"rb-sys",
|
|
353
382
|
]
|
|
354
383
|
|
|
@@ -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.
|
|
35
|
+
parsanol = { version = "0.19.0", features = ["ruby"] }
|
|
36
36
|
|
|
37
37
|
# Logging
|
|
38
38
|
log = "0.4"
|
data/lib/parsanol/parg/cli.rb
CHANGED
|
@@ -29,6 +29,13 @@ module Parsanol
|
|
|
29
29
|
repl <file> [--entry E] interactive parse loop (blank
|
|
30
30
|
line quits, :quit exits)
|
|
31
31
|
doc <file> [rule] print rule documentation
|
|
32
|
+
export <file|artifact.json> --target xgrammar
|
|
33
|
+
lower the compiled artifact to a
|
|
34
|
+
decoder-facing grammar (ruby#321)
|
|
35
|
+
lint <file> [--suggest-upgrades]
|
|
36
|
+
print lint errors (exit 1) and
|
|
37
|
+
warnings; the flag adds
|
|
38
|
+
machine-applicable upgrades
|
|
32
39
|
|
|
33
40
|
options:
|
|
34
41
|
--tables DIR directory for alt-from-table data (default:
|
|
@@ -51,6 +58,7 @@ module Parsanol
|
|
|
51
58
|
@argv.delete_at(index)
|
|
52
59
|
end
|
|
53
60
|
@json = !!@argv.delete("--json")
|
|
61
|
+
@suggest_upgrades = !!@argv.delete("--suggest-upgrades")
|
|
54
62
|
command = @argv.shift
|
|
55
63
|
case command
|
|
56
64
|
when "compile" then compile
|
|
@@ -60,6 +68,9 @@ module Parsanol
|
|
|
60
68
|
when "check" then check
|
|
61
69
|
when "repl" then repl
|
|
62
70
|
when "doc" then doc
|
|
71
|
+
when "lint" then lint
|
|
72
|
+
when "export" then export_grammar
|
|
73
|
+
when "diff" then diff_artifacts
|
|
63
74
|
when "lsp" then Lsp.new.run && 0
|
|
64
75
|
else
|
|
65
76
|
warn USAGE
|
|
@@ -84,6 +95,48 @@ module Parsanol
|
|
|
84
95
|
end
|
|
85
96
|
end
|
|
86
97
|
|
|
98
|
+
# `parg export FILE --target xgrammar`: the decoder-facing
|
|
99
|
+
# lowering (ruby#321). Reads a .parg (compiling it) or an
|
|
100
|
+
# artifact json, prints the target grammar on stdout.
|
|
101
|
+
def export_grammar
|
|
102
|
+
source = @argv.shift
|
|
103
|
+
target = (flag_value("--target") || "xgrammar").to_sym
|
|
104
|
+
json = if source.end_with?(".json")
|
|
105
|
+
File.read(source)
|
|
106
|
+
else
|
|
107
|
+
JSON.generate(compile_source(source).envelope)
|
|
108
|
+
end
|
|
109
|
+
print Export.call(json, target: target)
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# `parg lint FILE`: the lint suite on one grammar — errors
|
|
114
|
+
# first (exit 1), then every warning including the advisory
|
|
115
|
+
# strategies. --suggest-upgrades adds the machine-applicable
|
|
116
|
+
# upgrade suggestions on top.
|
|
117
|
+
def lint
|
|
118
|
+
source = @argv.shift
|
|
119
|
+
tables = @tables_dir || default_tables_dir(source)
|
|
120
|
+
document = Parsanol::PARG::Parser.new(PARG.read_utf8(source)).parse
|
|
121
|
+
Parsanol::PARG::Imports.merge!(document, [File.dirname(source)])
|
|
122
|
+
compiler = Parsanol::PARG::Compiler.new(document, tables)
|
|
123
|
+
document.rules.each_key { |rule| compiler.atom_for(rule) }
|
|
124
|
+
|
|
125
|
+
errors = Lints.errors(document, compiler)
|
|
126
|
+
errors.each { |error| puts "error: #{error}" }
|
|
127
|
+
return 1 unless errors.empty?
|
|
128
|
+
|
|
129
|
+
Lints.warnings(document, compiler).each { |warning| puts "warning: #{warning}" }
|
|
130
|
+
if @suggest_upgrades
|
|
131
|
+
Lints::CoreRuleDuplicates.new.warnings(document, compiler)
|
|
132
|
+
.each { |upgrade| puts "upgrade: #{upgrade}" }
|
|
133
|
+
end
|
|
134
|
+
0
|
|
135
|
+
rescue Parsanol::PARG::ParseError, Parsanol::PARG::Error => e
|
|
136
|
+
warn "#{e.class}: #{e.message}"
|
|
137
|
+
1
|
|
138
|
+
end
|
|
139
|
+
|
|
87
140
|
def compile
|
|
88
141
|
source = @argv.shift
|
|
89
142
|
out = flag_value("-o") || default_artifact_name(source)
|
|
@@ -162,6 +215,50 @@ module Parsanol
|
|
|
162
215
|
0
|
|
163
216
|
end
|
|
164
217
|
|
|
218
|
+
# parg diff OLD.artifact.json NEW.artifact.json [--json] [--suggest-bump] [--check LEVEL]
|
|
219
|
+
# Semantic classification of a grammar change (ruby#306): majors
|
|
220
|
+
# break consumers, minors extend them, patches preserve behavior.
|
|
221
|
+
def diff_artifacts
|
|
222
|
+
old_path = @argv.shift
|
|
223
|
+
new_path = @argv.shift
|
|
224
|
+
unless old_path && new_path
|
|
225
|
+
warn "usage: parsanol parg diff OLD.artifact.json NEW.artifact.json [--json] [--suggest-bump] [--check minor]"
|
|
226
|
+
return 2
|
|
227
|
+
end
|
|
228
|
+
suggest = !!@argv.delete("--suggest-bump")
|
|
229
|
+
check = @argv.delete("--check")
|
|
230
|
+
check_level = check && check.split("=", 2).last&.to_sym
|
|
231
|
+
|
|
232
|
+
old_env = JSON.parse(File.read(old_path))
|
|
233
|
+
new_env = JSON.parse(File.read(new_path))
|
|
234
|
+
findings = Diff.diff(old_env, new_env)
|
|
235
|
+
level = Diff.level(findings)
|
|
236
|
+
|
|
237
|
+
if @json
|
|
238
|
+
payload = {
|
|
239
|
+
level: level.to_s,
|
|
240
|
+
findings: findings.map { |f| { level: f.level.to_s, kind: f.kind.to_s, detail: f.detail } },
|
|
241
|
+
}
|
|
242
|
+
payload[:suggested] = Diff.suggest_bump(findings) if suggest
|
|
243
|
+
puts JSON.generate(payload)
|
|
244
|
+
else
|
|
245
|
+
if findings.empty?
|
|
246
|
+
puts "no semantic changes (#{level})"
|
|
247
|
+
else
|
|
248
|
+
findings.each { |f| puts "#{f.level.upcase} #{f.kind}: #{f.detail}" }
|
|
249
|
+
puts "level: #{level}"
|
|
250
|
+
end
|
|
251
|
+
puts Diff.suggest_bump(findings) if suggest
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if check_level
|
|
255
|
+
ok = Diff::SEVERITY[level] >= Diff::SEVERITY[check_level]
|
|
256
|
+
warn "check: #{level} is #{ok ? 'at least' : 'BELOW'} #{check_level}" unless @json
|
|
257
|
+
return ok ? 0 : 3
|
|
258
|
+
end
|
|
259
|
+
0
|
|
260
|
+
end
|
|
261
|
+
|
|
165
262
|
def parse
|
|
166
263
|
file = @argv.shift
|
|
167
264
|
entry = flag_value("--entry")
|
|
@@ -66,6 +66,19 @@ module Parsanol
|
|
|
66
66
|
|
|
67
67
|
# QoL shorthands (ABNF core rules): materialized on reference when
|
|
68
68
|
# the grammar does not define its own rule of that name.
|
|
69
|
+
# Character-class core rules (TODO.sota/9 T1, ruby#303): the RFC
|
|
70
|
+
# 5234 prelude that is expressible as a class. A grammar's own
|
|
71
|
+
# rule of the same name shadows these — fetch_rule checks the
|
|
72
|
+
# document first. ALNUM/WORD/ANY predate the RFC set.
|
|
73
|
+
# Unicode general categories (U3): one-letter groups plus the
|
|
74
|
+
# two-letter codes grammars actually reach for. Both regex
|
|
75
|
+
# engines carry the tables; this gate rejects misspellings at
|
|
76
|
+
# compile time instead of silently matching nothing.
|
|
77
|
+
GENERAL_CATEGORIES = %w[
|
|
78
|
+
L Lu Ll Lt Lm Lo M Mn Mc Me N Nd Nl No P Pc Pd Ps Pe Pi Pf Po
|
|
79
|
+
S Sm Sc Sk So Z Zs Zl Zp C Cc Cf Co Cn
|
|
80
|
+
].freeze
|
|
81
|
+
|
|
69
82
|
BUILTIN_CLASSES = {
|
|
70
83
|
"DIGIT" => [[0x30, 0x39]],
|
|
71
84
|
"ALPHA" => [[0x41, 0x5A], [0x61, 0x7A]],
|
|
@@ -73,15 +86,30 @@ module Parsanol
|
|
|
73
86
|
"WORD" => [[0x30, 0x39], [0x41, 0x5A], [0x5F, 0x5F], [0x61, 0x7A]],
|
|
74
87
|
"HEXDIG" => [[0x30, 0x39], [0x41, 0x46], [0x61, 0x66]],
|
|
75
88
|
"ANY" => [[0x00, 0x10FFFF]],
|
|
89
|
+
"BIT" => [[0x30, 0x31]],
|
|
90
|
+
"CHAR" => [[0x01, 0x7F]],
|
|
91
|
+
"CR" => [[0x0D, 0x0D]],
|
|
92
|
+
"CTL" => [[0x00, 0x1F], [0x7F, 0x7F]],
|
|
93
|
+
"DQUOTE" => [[0x22, 0x22]],
|
|
94
|
+
"HTAB" => [[0x09, 0x09]],
|
|
95
|
+
"LF" => [[0x0A, 0x0A]],
|
|
96
|
+
"OCTET" => [[0x00, 0xFF]],
|
|
97
|
+
"SP" => [[0x20, 0x20]],
|
|
98
|
+
"VCHAR" => [[0x21, 0x7E]],
|
|
99
|
+
"WSP" => [[0x20, 0x20], [0x09, 0x09]],
|
|
76
100
|
}.freeze
|
|
77
101
|
|
|
78
102
|
def compile
|
|
103
|
+
inject_core_rules!
|
|
79
104
|
expand_left_recursion!
|
|
80
105
|
@document.rules.each_key { |name| atom_for(name) }
|
|
81
106
|
errors = Lints.errors(@document, self)
|
|
82
107
|
raise CompileError, errors.join("\n") unless errors.empty?
|
|
83
108
|
|
|
84
|
-
|
|
109
|
+
# The author sees every finding; the envelope carries only the
|
|
110
|
+
# semantic subset (see Lints::Advisory).
|
|
111
|
+
@warnings = Lints.warnings(@document, self).map(&:to_s)
|
|
112
|
+
@envelope_warnings = Lints.envelope_warnings(@document, self)
|
|
85
113
|
failures = run_tests
|
|
86
114
|
raise CompileError, failures.join("\n") unless failures.empty?
|
|
87
115
|
|
|
@@ -120,6 +148,65 @@ module Parsanol
|
|
|
120
148
|
end.flatten
|
|
121
149
|
end
|
|
122
150
|
|
|
151
|
+
# The structured core rules (TODO.sota/9 T1, ruby#303): CRLF and
|
|
152
|
+
# LWSP are not character classes, so BUILTIN_CLASSES cannot serve
|
|
153
|
+
# them — they inject as real rules when referenced and missing,
|
|
154
|
+
# transitively (LWSP needs CRLF). Class-expressible members of
|
|
155
|
+
# the RFC 5234 set ride BUILTIN_CLASSES above; a grammar's own
|
|
156
|
+
# rule of the same name shadows either mechanism. Injected at the
|
|
157
|
+
# front so definitions precede references for the order lint.
|
|
158
|
+
STRUCTURED_CORE_RULES_SOURCE = <<~PARG
|
|
159
|
+
grammar CoreRules version "1.0.0" {
|
|
160
|
+
CRLF = %x0D.0A
|
|
161
|
+
LWSP = *( WSP / CRLF WSP )
|
|
162
|
+
}
|
|
163
|
+
PARG
|
|
164
|
+
|
|
165
|
+
STRUCTURED_CORE_RULE_NAMES = %w[CRLF LWSP].freeze
|
|
166
|
+
|
|
167
|
+
def inject_core_rules!
|
|
168
|
+
return if @core_rules_injected
|
|
169
|
+
|
|
170
|
+
@core_rules_injected = true
|
|
171
|
+
missing = STRUCTURED_CORE_RULE_NAMES.select do |name|
|
|
172
|
+
!@document.rules.key?(name) &&
|
|
173
|
+
@document.rules.each_value.any? { |node| references_atom?(node, name) }
|
|
174
|
+
end
|
|
175
|
+
return if missing.empty?
|
|
176
|
+
|
|
177
|
+
# Snapshot before the prelude merges: lints distinguish an
|
|
178
|
+
# authored shadow from an injected prelude rule.
|
|
179
|
+
@document.authored_rules ||= @document.rules.keys
|
|
180
|
+
prelude = Parser.new(STRUCTURED_CORE_RULES_SOURCE).parse
|
|
181
|
+
injected = {}
|
|
182
|
+
until missing.empty?
|
|
183
|
+
name = missing.pop
|
|
184
|
+
next if injected.key?(name) || @document.rules.key?(name)
|
|
185
|
+
|
|
186
|
+
node = prelude.rules.fetch(name)
|
|
187
|
+
injected[name] = node
|
|
188
|
+
STRUCTURED_CORE_RULE_NAMES.each do |dep|
|
|
189
|
+
missing << dep if !injected.key?(dep) && !@document.rules.key?(dep) &&
|
|
190
|
+
references_atom?(node, dep)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
# rules is deliberately read-only on the document: prepend in place.
|
|
194
|
+
@document.rules.replace(injected.merge(@document.rules))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def references_atom?(node, name)
|
|
198
|
+
walk = lambda do |n|
|
|
199
|
+
case n.kind
|
|
200
|
+
when :ref then return true if n.a == name
|
|
201
|
+
when :alt, :seq then n.a.each { |b| return true if references_atom?(b, name) }
|
|
202
|
+
when :cap, :pred then return true if references_atom?(n.b, name)
|
|
203
|
+
when :rep, :opt then return true if references_atom?(n.a, name)
|
|
204
|
+
end
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
walk.call(node) == true
|
|
208
|
+
end
|
|
209
|
+
|
|
123
210
|
def resolve_custom(name)
|
|
124
211
|
class_name = @document.customs.fetch(name)
|
|
125
212
|
klass = Object.const_get(class_name)
|
|
@@ -142,6 +229,7 @@ module Parsanol
|
|
|
142
229
|
end
|
|
143
230
|
|
|
144
231
|
def atom_for(name)
|
|
232
|
+
inject_core_rules!
|
|
145
233
|
expand_left_recursion! unless @expanded
|
|
146
234
|
@atom_cache[name] ||= begin
|
|
147
235
|
atom = build_atom(fetch_rule(name))
|
|
@@ -600,6 +688,14 @@ module Parsanol
|
|
|
600
688
|
Atoms::Str.new(node.a)
|
|
601
689
|
end
|
|
602
690
|
when :class then Atoms::Re.new(class_pattern(node.a))
|
|
691
|
+
when :category
|
|
692
|
+
code = node.a
|
|
693
|
+
unless GENERAL_CATEGORIES.include?(code)
|
|
694
|
+
raise CompileError,
|
|
695
|
+
"unknown general category %p{#{code}} -- use one of " \
|
|
696
|
+
"#{GENERAL_CATEGORIES.join(' ')}"
|
|
697
|
+
end
|
|
698
|
+
Atoms::Re.new("[\\p{#{code}}]")
|
|
603
699
|
when :until
|
|
604
700
|
# QoL: (*( !terminal any_char )) — consume everything up to
|
|
605
701
|
# (and excluding) the delimiter.
|
|
@@ -858,9 +954,10 @@ module Parsanol
|
|
|
858
954
|
def compute_first(node)
|
|
859
955
|
case node.kind
|
|
860
956
|
when :lit
|
|
861
|
-
# The zero-width marker ("" as name) consumes nothing:
|
|
862
|
-
# first set
|
|
863
|
-
|
|
957
|
+
# The zero-width marker ("" as name) consumes nothing: EPS in
|
|
958
|
+
# its first set marks the node nullable (the convention every
|
|
959
|
+
# nullable? check uses).
|
|
960
|
+
return Set.new([EPS]) if node.a.empty?
|
|
864
961
|
|
|
865
962
|
set = Set.new(node.a[0].bytes)
|
|
866
963
|
set += node.a[0].upcase.bytes + node.a[0].downcase.bytes if node.b
|
|
@@ -954,7 +1051,7 @@ module Parsanol
|
|
|
954
1051
|
"entries" => entries,
|
|
955
1052
|
"preprocess" => @document.preprocess,
|
|
956
1053
|
"tables" => table_manifest,
|
|
957
|
-
"lint" => { "order_warnings" => @
|
|
1054
|
+
"lint" => { "order_warnings" => @envelope_warnings.uniq },
|
|
958
1055
|
"default_entry" => begin
|
|
959
1056
|
resolve_default_entry
|
|
960
1057
|
rescue StandardError
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Parsanol
|
|
4
|
+
module PARG
|
|
5
|
+
# ruby#306: `parg diff OLD NEW` — semantic classification of grammar
|
|
6
|
+
# changes against the artifact contract. Atoms are compared by
|
|
7
|
+
# canonical rendering, never by JSON bytes, so compiler-version churn
|
|
8
|
+
# and key order never read as a grammar change.
|
|
9
|
+
#
|
|
10
|
+
# Levels: :major breaks consumers (capture/entry/branch removals,
|
|
11
|
+
# binding changes, mode change), :minor extends them (new entries,
|
|
12
|
+
# captures, branches), :patch is behavior-preserving detail (literal
|
|
13
|
+
# fixes, doc changes, added tests). Ambiguous cases land at the
|
|
14
|
+
# higher level with the evidence attached — never guessed down.
|
|
15
|
+
module Diff
|
|
16
|
+
Finding = Struct.new(:level, :kind, :detail)
|
|
17
|
+
|
|
18
|
+
SEVERITY = { none: 0, patch: 1, minor: 2, major: 3 }.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def diff(old_envelope, new_envelope)
|
|
23
|
+
findings = []
|
|
24
|
+
findings.concat(diff_mode(old_envelope, new_envelope))
|
|
25
|
+
findings.concat(diff_entries(old_envelope, new_envelope))
|
|
26
|
+
findings.concat(diff_bindings(old_envelope, new_envelope))
|
|
27
|
+
findings.concat(diff_tests(old_envelope, new_envelope))
|
|
28
|
+
findings
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def level(findings)
|
|
32
|
+
findings.reduce(:none) { |acc, finding| higher(acc, finding.level) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def higher(left, right)
|
|
36
|
+
SEVERITY[left] >= SEVERITY[right] ? left : right
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def suggest_bump(findings)
|
|
40
|
+
level = level(findings)
|
|
41
|
+
level == :none ? "no bump needed" : "bump #{level}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# -- entries ------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def diff_entries(old_env, new_env)
|
|
47
|
+
old_entries = old_env["entries"] || {}
|
|
48
|
+
new_entries = new_env["entries"] || {}
|
|
49
|
+
findings = (old_entries.keys - new_entries.keys).map do |name|
|
|
50
|
+
Finding.new(:major, :entry_removed, "entry #{name.inspect} removed")
|
|
51
|
+
end
|
|
52
|
+
(new_entries.keys - old_entries.keys).each do |name|
|
|
53
|
+
findings << Finding.new(:minor, :entry_added, "entry #{name.inspect} added")
|
|
54
|
+
end
|
|
55
|
+
old_entries.each do |name, old_entry|
|
|
56
|
+
new_entry = new_entries[name]
|
|
57
|
+
next unless new_entry
|
|
58
|
+
|
|
59
|
+
findings.concat(diff_entry(name, old_entry, new_entry))
|
|
60
|
+
end
|
|
61
|
+
findings
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def diff_entry(name, old_entry, new_entry)
|
|
65
|
+
old_atoms = old_entry.dig("grammar", "atoms") || []
|
|
66
|
+
new_atoms = new_entry.dig("grammar", "atoms") || []
|
|
67
|
+
old_root = old_entry.dig("grammar", "root")
|
|
68
|
+
new_root = new_entry.dig("grammar", "root")
|
|
69
|
+
return [Finding.new(:major, :entry_changed, "entry #{name.inspect} lost its root")] if old_root.nil? || new_root.nil?
|
|
70
|
+
|
|
71
|
+
old_render = render_atom(old_atoms, old_root)
|
|
72
|
+
new_render = render_atom(new_atoms, new_root)
|
|
73
|
+
return [] if old_render == new_render
|
|
74
|
+
|
|
75
|
+
findings = []
|
|
76
|
+
findings.concat(diff_captures(name, old_render, new_render))
|
|
77
|
+
findings.concat(diff_branches(name, mask(old_render), mask(new_render)))
|
|
78
|
+
if findings.empty?
|
|
79
|
+
findings << if mask(old_render) == mask(new_render)
|
|
80
|
+
# Same captures, same branches, same structure: the
|
|
81
|
+
# difference is inside literals, bounds, or trivia markers —
|
|
82
|
+
# behavior-preserving.
|
|
83
|
+
Finding.new(:patch, :literal_or_bound_change,
|
|
84
|
+
"entry #{name.inspect}: literal/bound/doc-level change")
|
|
85
|
+
else
|
|
86
|
+
# Structure reshuffled without a detectable capture or
|
|
87
|
+
# branch delta — evidence over a guess.
|
|
88
|
+
Finding.new(:minor, :structure_changed,
|
|
89
|
+
"entry #{name.inspect}: structure changed " \
|
|
90
|
+
"(no capture or branch removed): " \
|
|
91
|
+
"#{mask(old_render)[0, 60]} -> #{mask(new_render)[0, 60]}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
findings
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -- captures -----------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def diff_captures(entry_name, old_render, new_render)
|
|
100
|
+
old_names = capture_names(old_render)
|
|
101
|
+
new_names = capture_names(new_render)
|
|
102
|
+
removed = old_names - new_names
|
|
103
|
+
added = new_names - old_names
|
|
104
|
+
removed.map { |n| Finding.new(:major, :capture_removed, "entry #{entry_name.inspect}: capture #{n.inspect} removed") } +
|
|
105
|
+
added.map { |n| Finding.new(:minor, :capture_added, "entry #{entry_name.inspect}: capture #{n.inspect} added") }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def capture_names(render)
|
|
109
|
+
render.scan(/\(name "([^"]+)"/).flatten.uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# -- alternative branches ----------------------------------------
|
|
113
|
+
|
|
114
|
+
# A branch that stops matching is the ambiguous-breaking case:
|
|
115
|
+
# inputs it used to accept now reject. Removed branches are major;
|
|
116
|
+
# added-only is the documented minor.
|
|
117
|
+
def diff_branches(entry_name, old_render, new_render)
|
|
118
|
+
old_branches = alt_branches(old_render)
|
|
119
|
+
new_branches = alt_branches(new_render)
|
|
120
|
+
removed = old_branches - new_branches
|
|
121
|
+
added = new_branches - old_branches
|
|
122
|
+
removed.map { |b| Finding.new(:major, :branch_removed, "entry #{entry_name.inspect}: alternative branch #{b} no longer matches") } +
|
|
123
|
+
added.map { |b| Finding.new(:minor, :branch_added, "entry #{entry_name.inspect}: alternative branch #{b} added") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Literals masked: branch identity is structural, not spelling —
|
|
127
|
+
# a literal fix inside a branch is not that branch's removal.
|
|
128
|
+
def mask(render)
|
|
129
|
+
render.gsub(/str:"[^"]*"/, "str:#").gsub(/const:[^\s)]+/, "const:#")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Every alternative branch's canonical rendering, anywhere in the
|
|
133
|
+
# tree (alternatives inside repetitions, groups, captures…).
|
|
134
|
+
# Balanced-paren walk: branches nest.
|
|
135
|
+
def alt_branches(render)
|
|
136
|
+
branches = []
|
|
137
|
+
index = render.index("(alt ")
|
|
138
|
+
while index
|
|
139
|
+
start = index + 5
|
|
140
|
+
depth = 1
|
|
141
|
+
cursor = start
|
|
142
|
+
while depth.positive? && cursor < render.length
|
|
143
|
+
case render[cursor]
|
|
144
|
+
when "(" then depth += 1
|
|
145
|
+
when ")" then depth -= 1
|
|
146
|
+
end
|
|
147
|
+
cursor += 1
|
|
148
|
+
end
|
|
149
|
+
body = render[start...(cursor - 1)]
|
|
150
|
+
child_depth = 0
|
|
151
|
+
current = +""
|
|
152
|
+
body.each_char do |ch|
|
|
153
|
+
case ch
|
|
154
|
+
when "(" then child_depth += 1
|
|
155
|
+
when ")" then child_depth -= 1
|
|
156
|
+
when " "
|
|
157
|
+
if child_depth.zero?
|
|
158
|
+
branches << current unless current.empty?
|
|
159
|
+
current = +""
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
current << ch
|
|
164
|
+
end
|
|
165
|
+
branches << current unless current.empty?
|
|
166
|
+
index = render.index("(alt ", cursor)
|
|
167
|
+
end
|
|
168
|
+
branches.uniq.sort
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# -- mode ---------------------------------------------------------
|
|
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
|
|
177
|
+
|
|
178
|
+
[Finding.new(:major, :mode_changed,
|
|
179
|
+
"mode changed #{old_mode.inspect} -> #{new_mode.inspect} " \
|
|
180
|
+
"(every uncaptured node's tree shape changes)")]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# -- bindings -----------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def diff_bindings(old_env, new_env)
|
|
186
|
+
old_bindings = old_env["bindings"]
|
|
187
|
+
new_bindings = new_env["bindings"]
|
|
188
|
+
return [] if old_bindings == new_bindings
|
|
189
|
+
return [Finding.new(:minor, :bindings_added, "bindings section added")] if old_bindings.nil?
|
|
190
|
+
return [Finding.new(:major, :bindings_removed, "bindings section removed")] if new_bindings.nil?
|
|
191
|
+
|
|
192
|
+
[Finding.new(:major, :bindings_changed,
|
|
193
|
+
"bindings changed — binding paths/types re-validate " \
|
|
194
|
+
"against the tree")]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# -- tests --------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def diff_tests(old_env, new_env)
|
|
200
|
+
old_tests = old_env["tests"].to_a
|
|
201
|
+
new_tests = new_env["tests"].to_a
|
|
202
|
+
return [] if old_tests == new_tests
|
|
203
|
+
|
|
204
|
+
# Pins per kind: a pin whose INPUT changed alongside a literal
|
|
205
|
+
# fix is the pin following the grammar (patch), not a removal.
|
|
206
|
+
old_counts = old_tests.each_with_object(Hash.new(0)) { |t, counts| counts[t["kind"]] += 1 }
|
|
207
|
+
new_counts = new_tests.each_with_object(Hash.new(0)) { |t, counts| counts[t["kind"]] += 1 }
|
|
208
|
+
dropped = old_counts.keys.select { |kind| new_counts[kind] < old_counts[kind] }
|
|
209
|
+
unless dropped.empty?
|
|
210
|
+
return [Finding.new(:major, :tests_removed,
|
|
211
|
+
"#{dropped.join(', ')} pin(s) removed — the contract weakened")]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
[Finding.new(:patch, :tests_added,
|
|
215
|
+
"test pins changed/added (#{old_tests.size} -> #{new_tests.size})")]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# -- canonical rendering ------------------------------------------
|
|
219
|
+
|
|
220
|
+
# Canonical s-expression of one atom index. Entities resolve; keyed
|
|
221
|
+
# hashes render in fixed field order; nothing escapes — this is a
|
|
222
|
+
# diff key, not output text.
|
|
223
|
+
def render_atom(atoms, idx, depth = 0)
|
|
224
|
+
return "…" if depth > 64
|
|
225
|
+
|
|
226
|
+
a = atoms[idx]
|
|
227
|
+
return "missing:#{idx}" if a.nil?
|
|
228
|
+
|
|
229
|
+
if (str = a["Str"])
|
|
230
|
+
"str:#{str['pattern'].inspect}"
|
|
231
|
+
elsif (re = a["Re"])
|
|
232
|
+
"re:#{re['pattern']}"
|
|
233
|
+
elsif (sequence = a["Sequence"])
|
|
234
|
+
"(seq #{sequence['atoms'].map { |i| render_atom(atoms, i, depth + 1) }.join(' ')})"
|
|
235
|
+
elsif (alt = a["Alternative"])
|
|
236
|
+
"(alt #{alt['atoms'].map { |i| render_atom(atoms, i, depth + 1) }.join(' ')})"
|
|
237
|
+
elsif (rep = a["Repetition"])
|
|
238
|
+
"(rep #{rep['min']} #{rep['max'].nil? ? '*' : rep['max']} #{rep['tag']} #{render_atom(atoms, rep['atom'], depth + 1)})"
|
|
239
|
+
elsif (named = a["Named"])
|
|
240
|
+
"(name \"#{named['name']}\" #{render_atom(atoms, named['atom'], depth + 1)})"
|
|
241
|
+
elsif (entity = a["Entity"])
|
|
242
|
+
render_atom(atoms, entity["atom"], depth + 1)
|
|
243
|
+
elsif (look = a["Lookahead"])
|
|
244
|
+
"(look #{look['positive'] ? '&' : '!'} #{render_atom(atoms, look['atom'], depth + 1)})"
|
|
245
|
+
elsif (behind = a["Lookbehind"])
|
|
246
|
+
"(behind #{behind['positive'] ? '&' : '!'} #{behind['look'].inspect})"
|
|
247
|
+
elsif a["Cut"]
|
|
248
|
+
"cut"
|
|
249
|
+
elsif (ignore = a["Ignore"])
|
|
250
|
+
"(ignore #{render_atom(atoms, ignore['atom'], depth + 1)})"
|
|
251
|
+
elsif (trivia = a["Trivia"])
|
|
252
|
+
"(trivia #{render_atom(atoms, trivia['atom'], depth + 1)})"
|
|
253
|
+
elsif (capture_trivia = a["TriviaCapture"])
|
|
254
|
+
"(trivia-cap #{capture_trivia['rules'].map { |m, k| "#{m}=#{k}" }.join(',')} " \
|
|
255
|
+
"#{render_atom(atoms, capture_trivia['atom'], depth + 1)})"
|
|
256
|
+
elsif (constant = a["Constant"])
|
|
257
|
+
"const:#{constant['value'].inspect}"
|
|
258
|
+
else
|
|
259
|
+
"unknown:#{a.keys.first}"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|