parsanol 1.3.107-aarch64-linux → 1.3.109-aarch64-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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a248ea2b74e5e7a7be04c806fd97f180b76514e96281f5daaf04a99e9ca317e
4
- data.tar.gz: bf7fe292aadabb0107f3353f6b0e96c0f6d2b0c1cc4006398590d0a34b7796c6
3
+ metadata.gz: c17015cb92f4e965c312eb69e7bb14e212700d6660650061ccfb0bb59da35cde
4
+ data.tar.gz: d246068f5c2b4b6bfe56aac5da14e450b29a8b39f36836ea5da65ab2d15a56af
5
5
  SHA512:
6
- metadata.gz: ef2edf2620ccaf4369731c83f9bdc5ae063898b0dcc5813dbbfa5a690e2e85b7f99e47dea01cca770198a9c406b1f5c147c540782b84e111d34d6e75be2757d3
7
- data.tar.gz: 586b3121956c700428f7bedbc3218ece7a15c6aaaf79f49216ad3a724e4ee2a04e50934f36a57a055386f9342e141a1d8942fc464fbb0bf2d3f325ad6a6abc9d
6
+ metadata.gz: fe6458c0af76600f87a58c24c00405e0148dfd50aa75b694761f1c2a88a480ed9a8113419f65eaf8407dcdcfe5ae2e4b920df995018ad8d6440d45962619d4fb
7
+ data.tar.gz: e14f4c3c3bc2ed0decdae5d6339b6d06c7978f7e75e767a4f8a2cc48ffb93ae461792660eae5d81e6b26d60ddb6c248cda296834cc63acb4de98a13462150a04
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.2/parsanol_native.so
3
- size: 40743256 bytes
4
- sha256: f27084008a8a2764ecb363b009685fd79d6b549460089afd8cffdeee1d286d83
3
+ size: 42420800 bytes
4
+ sha256: 81dd5dcbde9d4188c86c316102dabdb522048fe218134d89180db1334f5d2e77
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.3/parsanol_native.so
3
- size: 40978344 bytes
4
- sha256: 9c09c91329de7f138e37a561a3f700520f37c39968d201c941bc58636cbf7b9b
3
+ size: 42655808 bytes
4
+ sha256: 3c87af92ff773b9d78c286d55c8c82c7fd11d1331e0a09ca9942c28fc184ac47
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.4/parsanol_native.so
3
- size: 40973960 bytes
4
- sha256: 967c4b42655705c1aa762e03499986fc185657ff4008297e093c73ecaa8cb60a
3
+ size: 42651368 bytes
4
+ sha256: 4bc273e8b1473a31c24ef9b882ee53b001a337c620467000fe4021954bb640eb
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/4.0/parsanol_native.so
3
- size: 40983992 bytes
4
- sha256: 5de5e2e7a682e336ae7f34c7d3967341320c85b4fc969e41354ed46498b892bb
3
+ size: 42661424 bytes
4
+ sha256: 6c9c533b9b53407c3ef7c2c789c610fe35e860a470d7406dad4629be3f94cbf5
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/native/libparsanol.so
3
- size: 36501808 bytes
4
- sha256: aba1f5e1e7283015e5650abd249c27d9f0e095747a3bd1921aa981a1d9c64a10
3
+ size: 36759888 bytes
4
+ sha256: 885d841e5628d9fae0b43e78bd28ef5f27195ca0f3b543d35aaba7e8167ef2f4
@@ -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
- @warnings = Lints.warnings(@document, self)
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: its
862
- # first set is empty, which also marks the node nullable.
863
- return Set.new if node.a.empty?
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" => @warnings.uniq },
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
@@ -18,15 +18,20 @@ module Parsanol
18
18
  Test = Struct.new(:entry, :kind, :input, :expect)
19
19
 
20
20
  attr_accessor :grammar_name, :version, :source, :uses, :skip_declaration, :skip_source,
21
- :atomic_rules, :skip_capture_key
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).
24
24
  attr_reader :resyncs
25
25
  attr_reader :rules, :bindings, :preprocess, :entries, :docs, :tests, :render, :derive,
26
+ :declaration_order,
26
27
  :customs, :states
27
28
  # Entries authored in this file (pre-import); nil-entry tests resolve
28
29
  # against these first.
29
30
  attr_accessor :own_entries
31
+ # Rule names the grammar itself wrote, snapshotted before the
32
+ # compiler injects core rules — lints must not mistake an injected
33
+ # prelude rule for an authored shadow.
34
+ attr_accessor :authored_rules
30
35
 
31
36
  def initialize
32
37
  @grammar_name = nil
@@ -38,6 +43,7 @@ module Parsanol
38
43
  @bindings = {}
39
44
  @preprocess = {}
40
45
  @entries = {}
46
+ @declaration_order = []
41
47
  @docs = {}
42
48
  @tests = []
43
49
  @uses = []
@@ -48,6 +54,8 @@ module Parsanol
48
54
  # suppressed inside their bodies (token-shaped rules match
49
55
  # contiguously).
50
56
  @atomic_rules = []
57
+ @brace_rules = []
58
+ @authored_rules = nil
51
59
  # R2 declared resync: rule -> skip pattern. Recovery consumes
52
60
  # each pattern match at a failure point as annotated error text
53
61
  # and resumes the iteration after it.