parsanol 1.3.54-aarch64-linux → 1.3.57-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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/exe/parsanol +7 -0
  3. data/lib/parsanol/3.2/parsanol_native.so +2 -2
  4. data/lib/parsanol/3.3/parsanol_native.so +2 -2
  5. data/lib/parsanol/3.4/parsanol_native.so +2 -2
  6. data/lib/parsanol/4.0/parsanol_native.so +2 -2
  7. data/lib/parsanol/atoms/base.rb +1 -1
  8. data/lib/parsanol/atoms/cut.rb +1 -1
  9. data/lib/parsanol/atoms/repetition.rb +13 -2
  10. data/lib/parsanol/cause.rb +1 -1
  11. data/lib/parsanol/error_reporter/contextual.rb +1 -1
  12. data/lib/parsanol/lazy_result.rb +12 -3
  13. data/lib/parsanol/native/libparsanol.so +0 -0
  14. data/lib/parsanol/native/serializer.rb +5 -2
  15. data/lib/parsanol/native/transformer.rb +12 -8
  16. data/lib/parsanol/native.rb +4 -2
  17. data/lib/parsanol/parg/artifact.rb +213 -0
  18. data/lib/parsanol/parg/authoring.rb +110 -0
  19. data/lib/parsanol/parg/bindings.rb +129 -0
  20. data/lib/parsanol/parg/cli.rb +251 -0
  21. data/lib/parsanol/parg/compiler.rb +465 -0
  22. data/lib/parsanol/parg/derive.rb +28 -0
  23. data/lib/parsanol/parg/document.rb +69 -0
  24. data/lib/parsanol/parg/error.rb +18 -0
  25. data/lib/parsanol/parg/frontend.rb +173 -0
  26. data/lib/parsanol/parg/import.rb +34 -0
  27. data/lib/parsanol/parg/importers/abnf.rb +299 -0
  28. data/lib/parsanol/parg/importers/ebnf.rb +201 -0
  29. data/lib/parsanol/parg/importers/pest.rb +314 -0
  30. data/lib/parsanol/parg/imports.rb +87 -0
  31. data/lib/parsanol/parg/lexer.rb +72 -0
  32. data/lib/parsanol/parg/lints.rb +171 -0
  33. data/lib/parsanol/parg/lsp.rb +208 -0
  34. data/lib/parsanol/parg/lutaml.rb +67 -0
  35. data/lib/parsanol/parg/node.rb +20 -0
  36. data/lib/parsanol/parg/parser.rb +458 -0
  37. data/lib/parsanol/parg/preprocess.rb +36 -0
  38. data/lib/parsanol/parg/render.rb +44 -0
  39. data/lib/parsanol/parg/selfhost.rb +49 -0
  40. data/lib/parsanol/parg/visitor.rb +64 -0
  41. data/lib/parsanol/parg.rb +38 -0
  42. data/lib/parsanol/parser.rb +1 -1
  43. data/lib/parsanol/pattern.rb +1 -1
  44. data/lib/parsanol/pool.rb +1 -1
  45. data/lib/parsanol/rope.rb +1 -1
  46. data/lib/parsanol/slice.rb +4 -6
  47. data/lib/parsanol/source/line_cache.rb +1 -1
  48. data/lib/parsanol/source.rb +2 -2
  49. data/lib/parsanol/version.rb +1 -1
  50. data/lib/parsanol/vm.rb +95 -5
  51. data/lib/parsanol.rb +9 -0
  52. data/parsanol.gemspec +3 -1
  53. metadata +31 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be8d4f6a4a67946ddbbb813adf02a30954c5416bcc90f63cf96793a054e0c254
4
- data.tar.gz: 7e9bb6ef7ca17ae816aa72158f1ea3768250c5c92359f83bea8475e4e38dcce3
3
+ metadata.gz: 0010db3a97f920fe917ed34b033fa2a0d25f4f18d1c203926960114fa32ab95c
4
+ data.tar.gz: 68053831f60c7c2037113bff3b85a133a35f78d410bc4359955faa0b926d1e79
5
5
  SHA512:
6
- metadata.gz: 9e0bc3a1df3681709814e3365f2d953fe651a2389e6581034f5e499e0508a01b708d155803209c5df605ee88d6d83bc1451dbcccf99d2af226be1aa81d62df3c
7
- data.tar.gz: a303efad410d36a94a5ad57533a33be0a65eecd3d00ee5ae76661809366bbd02833fced16a67e9749900d7ddb4da99d4a4697e24a24492f093dbd2b348c24028
6
+ metadata.gz: b22462103615fc1532dc323812f29718fc75c3737124200b0ffd0f88f20a17207ec2523af81e6a346acfed97a88259cfa2d18de08223ee34c06566253130f51b
7
+ data.tar.gz: df2a01166e5c881c238a2d624d549dd5601eb2698a5f6afcaf1280443eb0ccb83c9cdadc35aec687cb94035a536cdf04e18129f3a01512a71b7eaa313c7bf0ca
data/exe/parsanol ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "parsanol"
5
+ require "parsanol/parg/cli"
6
+ argv = ARGV[0] == "parg" ? ARGV[1..] : ARGV
7
+ exit Parsanol::PARG::CLI.run(argv)
@@ -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: 34576408 bytes
4
- sha256: b656b991f63ef61537344a63ecc924cbd7bb244b3b423140c4f25c55eca71720
3
+ size: 38354184 bytes
4
+ sha256: fcb2235deaee5d15ba1da7efeca64362789964ddea627d39b90e7b5f266fad27
@@ -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: 34819576 bytes
4
- sha256: 8901b2f0c271f2c433dcc5dd040e6a4733e361a8b8c12af36614d37efb6988ec
3
+ size: 38585032 bytes
4
+ sha256: 2f78aabde520a55398f7fcfe162a387d385f67f79744b2351131029a4baf2985
@@ -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: 34807032 bytes
4
- sha256: 07fa808465df343a2ace7616fbde2a438739c386acdea32ea5d4f3b4fe38248c
3
+ size: 38580784 bytes
4
+ sha256: d8073227567e6b43b5657910405c9774a5f877931d3d17338faeae86c50cb861
@@ -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: 34817040 bytes
4
- sha256: 0a32795747cbecb3822ed292a87deec5612293fe055858e1f2c3e3c6785db190
3
+ size: 38590792 bytes
4
+ sha256: 28d427a1a078ed7933ab6e3fa05876932fb8ab064549290f28f2ec80308e9209
@@ -208,7 +208,7 @@ module Parsanol
208
208
 
209
209
  # Converts raw input to Source if needed.
210
210
  def normalize_input(source)
211
- source.respond_to?(:line_and_column) ? source : Parsanol::Source.new(source)
211
+ source.is_a?(Parsanol::Source) ? source : Parsanol::Source.new(source)
212
212
  end
213
213
 
214
214
  # Detects if we're in a Parser context.
@@ -42,7 +42,7 @@ module Parsanol
42
42
  # 1. Mark the current position as a cut point
43
43
  # 2. Empty the backtrack stack (we won't backtrack past here)
44
44
  # 3. Aggressively evict cache entries before this position
45
- context.cut!(source.bytepos) if context.respond_to?(:cut!)
45
+ context.cut!(source.bytepos)
46
46
 
47
47
  [success, value]
48
48
  end
@@ -66,7 +66,7 @@ module Parsanol
66
66
  # @return [Array(Boolean, Object)] result
67
67
  def try(source, context, consume_all)
68
68
  # Check for tree memoization support
69
- if context.respond_to?(:use_tree_memoization?) && context.use_tree_memoization?
69
+ if context.use_tree_memoization?
70
70
  return with_tree_cache(source, context, consume_all)
71
71
  end
72
72
 
@@ -183,6 +183,7 @@ module Parsanol
183
183
  last_failure = nil
184
184
 
185
185
  loop do
186
+ iteration_start = source.bytepos
186
187
  success, value = @parslet.apply(source, context, false)
187
188
 
188
189
  unless success
@@ -193,6 +194,11 @@ module Parsanol
193
194
  occurrence += 1
194
195
  result[occurrence] = value
195
196
 
197
+ # A body match that consumes nothing cannot make progress;
198
+ # looping again would re-match empty forever. Count the empty
199
+ # match, then stop — the min check below applies as usual.
200
+ break if source.bytepos == iteration_start
201
+
196
202
  break if @max && occurrence >= @max
197
203
  end
198
204
 
@@ -254,6 +260,7 @@ module Parsanol
254
260
  last_failure = nil
255
261
 
256
262
  loop do
263
+ iteration_start = source.bytepos
257
264
  success, value = @parslet.apply(source, context, false)
258
265
 
259
266
  unless success
@@ -265,6 +272,10 @@ module Parsanol
265
272
  result[occurrence] = value
266
273
  positions << source.bytepos
267
274
 
275
+ # Zero-width body match: count it, stop iterating (the min
276
+ # check below applies as usual) — mirrors try_general.
277
+ break if source.bytepos == iteration_start
278
+
268
279
  break if @max && occurrence >= @max
269
280
  end
270
281
 
@@ -287,7 +298,7 @@ module Parsanol
287
298
  context.store_tree_memo(cache_key, start_pos,
288
299
  result[1, occurrence], end_pos)
289
300
  end
290
- context.release_array(positions) if context.respond_to?(:release_array)
301
+ context.release_array(positions)
291
302
 
292
303
  # Check minimum
293
304
  if occurrence < @min
@@ -74,7 +74,7 @@ module Parsanol
74
74
  line_num, col_num = @source.line_and_column(@position)
75
75
 
76
76
  formatted_msg = @message.map do |msg|
77
- msg.respond_to?(:to_slice) ? msg.content.inspect : msg.to_s
77
+ msg.is_a?(Parsanol::Slice) ? msg.content.inspect : msg.to_s
78
78
  end.join
79
79
 
80
80
  "#{formatted_msg} at line #{line_num} char #{col_num}#{@parsing_label}."
@@ -72,7 +72,7 @@ module Parsanol
72
72
  cause = super
73
73
 
74
74
  # Apply label if the atom has one
75
- if atom.respond_to?(:label) && (lbl = atom.label)
75
+ if atom.is_a?(Parsanol::Atoms::Base) && (lbl = atom.label)
76
76
  maybe_update_label(lbl, src.pos.bytepos)
77
77
  cause.set_label(@active_label_text)
78
78
  end
@@ -103,6 +103,15 @@ module Parsanol
103
103
 
104
104
  alias kind_of? is_a?
105
105
 
106
+ # The delegated interface: Array's full class contract (including
107
+ # Enumerable), computed once against the type rather than probed on
108
+ # instances.
109
+ ARRAY_METHODS = (
110
+ Array.instance_methods(true) + Enumerable.instance_methods + [:to_ary]
111
+ ).freeze
112
+
113
+ private_constant :ARRAY_METHODS
114
+
106
115
  # Respond to array methods.
107
116
  #
108
117
  # @param method [Symbol] Method name
@@ -110,7 +119,7 @@ module Parsanol
110
119
  # @return [Boolean] true if responds
111
120
  #
112
121
  def respond_to?(method, include_private = false)
113
- super || to_a.respond_to?(method, include_private)
122
+ super || ARRAY_METHODS.include?(method)
114
123
  end
115
124
 
116
125
  # Delegate unknown methods to materialized array.
@@ -121,7 +130,7 @@ module Parsanol
121
130
  # @return [Object] Result of method call
122
131
  #
123
132
  def method_missing(method, ...)
124
- if to_a.respond_to?(method)
133
+ if ARRAY_METHODS.include?(method)
125
134
  to_a.public_send(method, ...)
126
135
  else
127
136
  super
@@ -135,7 +144,7 @@ module Parsanol
135
144
  # @return [Boolean] true if method is supported
136
145
  #
137
146
  def respond_to_missing?(method, include_private = false)
138
- to_a.respond_to?(method, include_private) || super
147
+ ARRAY_METHODS.include?(method) || super
139
148
  end
140
149
 
141
150
  # Compare with another object.
Binary file
@@ -95,9 +95,12 @@ module Parsanol
95
95
 
96
96
  def serialize_re(atom)
97
97
  # Ruby's Regexp#to_s produces "(?-mix:pattern)" format
98
- # We need to extract just the pattern for the Rust parser
98
+ # The pattern must reach the Rust regex engine verbatim, including
99
+ # inline flag groups: stripping (?i:...) silently turned every
100
+ # case-insensitive regex into a case-sensitive one on the native
101
+ # path (the Ruby atom still compiled it with the flag, so the two
102
+ # engines disagreed). Rust regex understands the same inline groups.
99
103
  pattern = atom.match
100
- pattern = ::Regexp.last_match(1) if pattern =~ /^\(\?[-mix]*:(.+)\)$/
101
104
  {
102
105
  "Re" => {
103
106
  "pattern" => pattern,
@@ -391,15 +391,19 @@ module Parsanol
391
391
  return left.is_a?(Hash) ? left.merge(right) : left + right
392
392
  end
393
393
 
394
- if left.respond_to?(:to_str) && right.respond_to?(:to_str)
395
- return right if right.respond_to?(:to_slice)
396
- return left if left.respond_to?(:to_slice)
397
-
398
- return left.to_str + right.to_str
394
+ # String-like = String or Slice (both implement to_str). When
395
+ # only ONE side is string-like, the OTHER side wins — the
396
+ # original duck-typed priority, preserved exactly.
397
+ left_stringlike = left.is_a?(String) || left.is_a?(Parsanol::Slice)
398
+ right_stringlike = right.is_a?(String) || right.is_a?(Parsanol::Slice)
399
+ if left_stringlike && right_stringlike
400
+ return right if right.is_a?(Parsanol::Slice)
401
+ return left if left.is_a?(Parsanol::Slice)
402
+
403
+ return left + right
399
404
  end
400
-
401
- return left if right.respond_to?(:to_str)
402
- return right if left.respond_to?(:to_str)
405
+ return left if right_stringlike
406
+ return right if left_stringlike
403
407
 
404
408
  return left + [right] if right.is_a?(Hash)
405
409
  return [left] + right if left.is_a?(Hash)
@@ -216,7 +216,9 @@ module Parsanol
216
216
  # cause from that directly — the failure path never needs a
217
217
  # reporter reparse. The single interpreter pass below stays only
218
218
  # to recover inputs from grammars native cannot express.
219
- if grammar.respond_to?(:parse) && (m = error.message.match(NATIVE_POS_MARKER))
219
+ parsanol_grammar =
220
+ grammar.is_a?(Parsanol::Atoms::Base) || grammar.is_a?(Parsanol::Parser)
221
+ if parsanol_grammar && (m = error.message.match(NATIVE_POS_MARKER))
220
222
  source = Parsanol::Source.new(input)
221
223
  success, value = grammar.run_with_context(source, nil, true)
222
224
  return grammar.finalize_result(value) if success
@@ -227,7 +229,7 @@ module Parsanol
227
229
  raise Parsanol::ParseFailed.new(cause.to_s, cause)
228
230
  end
229
231
 
230
- if grammar.respond_to?(:parse)
232
+ if parsanol_grammar
231
233
  # No native diagnostics (e.g. incomplete-input errors): one
232
234
  # interpreter pass with the reporter attached — a success
233
235
  # recovers the tree, a failure raises the cause tree.
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Parsanol
7
+ module PARG
8
+ # A compiled artifact envelope: load, verify checksum, parse inputs.
9
+ #
10
+ # The envelope's grammar section is the portable JSON the native engines
11
+ # consume; the embedded PARG source lets a Ruby runtime recompile the atom
12
+ # tree locally without any other dependency.
13
+ class Artifact
14
+ attr_reader :envelope, :path, :tables_dir
15
+
16
+ def self.load(path, tables_dir: nil)
17
+ envelope = JSON.parse(File.read(path))
18
+ new(envelope, path, tables_dir || File.dirname(path))
19
+ end
20
+
21
+ def self.from_json(text, tables_dir: nil)
22
+ new(JSON.parse(text), nil, tables_dir)
23
+ end
24
+
25
+ SUPPORTED_SHAPE = "parsanol-tree/v2"
26
+
27
+ def initialize(envelope, path, tables_dir)
28
+ unless envelope["shape"] == SUPPORTED_SHAPE
29
+ raise ArtifactError,
30
+ "unsupported artifact shape #{envelope['shape'].inspect} " \
31
+ "(this runtime implements #{SUPPORTED_SHAPE.inspect})"
32
+ end
33
+ @envelope = envelope
34
+ @path = path
35
+ @tables_dir = tables_dir
36
+ verify_checksum!
37
+ @compiler = nil
38
+ end
39
+
40
+ def version = envelope["version"]
41
+
42
+ def entries = envelope["entries"].keys
43
+
44
+ def entry(name)
45
+ envelope["entries"].fetch(name) do
46
+ raise ArtifactError, "unknown entry #{name.inspect}"
47
+ end
48
+ end
49
+
50
+ # Parse via the native engine: the envelope's grammar section is the
51
+ # exact portable JSON the Rust side registers, so the artifact parse
52
+ # path is a straight register-and-run — no Ruby recompilation. The
53
+ # Ruby atom runtime (mode: :ruby) remains available explicitly, and
54
+ # serves as the fallback on platforms without the extension.
55
+ def parse(entry_name, input, mode: :native)
56
+ case mode
57
+ when :native
58
+ if Native.available?
59
+ return Native.parse(JSON.generate(entry(entry_name).fetch("grammar")), input)
60
+ end
61
+
62
+ root_atom(entry_name).parse(input)
63
+ when :ruby
64
+ root_atom(entry_name).parse(input)
65
+ else
66
+ raise ArgumentError, "unknown mode #{mode.inspect} (use :native or :ruby)"
67
+ end
68
+ end
69
+
70
+ def apply_bindings(entry_name, shape)
71
+ Bindings.apply(self, entry(entry_name), shape)
72
+ end
73
+
74
+ # Render the identifier string from a bound attribute map (F6).
75
+ def render(_entry_name, bound, variant: "default")
76
+ Render.apply(envelope["render"] || {}, variant, bound)
77
+ end
78
+
79
+ def render_string(entry_name, input, variant: "default")
80
+ render(entry_name, parse_and_bind(entry_name, input), variant: variant)
81
+ end
82
+
83
+ # Evaluate a named derive spec against a bound map (F6).
84
+ def derive(name, bound)
85
+ Derive.apply(envelope["derive"] || {}, name, bound)
86
+ end
87
+
88
+ def derive_string(entry_name, input, name)
89
+ derive(name, parse_and_bind(entry_name, input))
90
+ end
91
+
92
+ def parse_and_bind(entry_name, input, mode: :native)
93
+ apply_bindings(entry_name, parse(entry_name, input, mode: mode))
94
+ end
95
+
96
+ # Re-run the grammar's in-file tests against this artifact. Returns
97
+ # the failure descriptions; empty means every test passes.
98
+ def run_tests(mode: :native)
99
+ run_test_list(envelope.fetch("tests", []), mode: mode)
100
+ end
101
+
102
+ # Run an external test list (suite files yield Document::Test
103
+ # structs; embedded tests are artifact-shaped hashes).
104
+ def run_test_list(tests, mode: :native)
105
+ tests.filter_map do |test|
106
+ hash = test.is_a?(Document::Test) ? test_to_hash(test) : test
107
+ entry_name = hash["entry"] || entries.first
108
+ begin
109
+ bound = parse_and_bind(entry_name, hash["input"], mode: mode)
110
+ if hash["kind"] == "reject"
111
+ "test #{hash['input'].inspect}: expected the input to be rejected"
112
+ elsif hash["kind"] == "example"
113
+ expect = hash["expect"].to_h { |key, value| [key.to_sym, value] }
114
+ mismatched = expect.reject { |key, value| bound.key?(key) && bound[key] == value }
115
+ next if mismatched.empty?
116
+
117
+ "test #{hash['input'].inspect}: expected captures " \
118
+ "#{mismatched.transform_values(&:inspect).inspect}, got #{bound.inspect}"
119
+ end
120
+ rescue Parsanol::ParseFailed
121
+ unless hash["kind"] == "reject"
122
+ "test #{hash['input'].inspect}: expected the input to parse"
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def test_to_hash(test)
129
+ {
130
+ "entry" => test.entry,
131
+ "kind" => test.kind.to_s,
132
+ "input" => test.input,
133
+ "expect" => test.expect,
134
+ }
135
+ end
136
+
137
+ # Rule documentation embedded from ## doc comments.
138
+ def rule_docs = envelope.fetch("docs", {})
139
+
140
+ # Structured parse diagnostics (PN 2): {offset, message} for the
141
+ # deepest failure of the most recent parse attempt on this entry.
142
+ def parse_with_diagnostics(entry_name, input, mode: :native)
143
+ shape = parse(entry_name, input, mode: mode)
144
+ { "ok" => true, "offset" => nil, "message" => nil, "shape" => shape }
145
+ rescue Parsanol::ParseFailed => e
146
+ cause = deepest_cause(e.parse_failure_cause)
147
+ { "ok" => false, "offset" => cause&.position,
148
+ "message" => cause&.message || e.message, "shape" => nil }
149
+ end
150
+
151
+ def deepest_cause(cause)
152
+ return cause if cause.nil? || cause.children.empty?
153
+
154
+ deepest = cause.children.filter_map { |child| deepest_cause(child) }
155
+ .max_by { |node| node.position.to_i }
156
+ (deepest&.position.to_i >= cause.position.to_i ? deepest : cause)
157
+ end
158
+
159
+ def table_rows(name)
160
+ @table_cache ||= {}
161
+ return @table_cache[name] if @table_cache.key?(name)
162
+
163
+ declared = envelope["tables"].fetch(name) do
164
+ raise ArtifactError, "artifact does not declare table #{name.inspect}"
165
+ end
166
+ if declared.is_a?(Hash) && declared.key?("rows")
167
+ return @table_cache[name] = declared["rows"]
168
+ end
169
+
170
+ file = declared
171
+ path = File.join(@tables_dir.to_s, file)
172
+ raw = path.end_with?(".json") ? JSON.parse(File.read(path)) : YAML.safe_load_file(path, aliases: true)
173
+ rows = case raw
174
+ when Hash
175
+ raw.map { |key, value| { "name" => key.to_s }.merge(value.to_h.transform_keys(&:to_s)) }
176
+ when Array then raw.map { |row| row.to_h.transform_keys(&:to_s) }
177
+ else raise ArtifactError, "table #{name.inspect} must be a map or array"
178
+ end
179
+ @table_cache[name] = rows
180
+ end
181
+
182
+ def compiler
183
+ @compiler ||= begin
184
+ document = Parser.new(envelope.fetch("source")).parse
185
+ # `use`-importing grammars carry dotted cross-grammar rule
186
+ # references that only resolve once the imported sources merge;
187
+ # without this the pure-Ruby path fails on artifacts whose
188
+ # native atom graphs inline everything. The imported sources
189
+ # sit beside the artifact (vendors ship them together).
190
+ imports_dir = path && File.directory?(File.dirname(path)) ? File.dirname(path) : nil
191
+ Imports.merge!(document, [imports_dir].compact) if imports_dir
192
+ Compiler.new(document, @tables_dir)
193
+ end
194
+ end
195
+
196
+ def root_atom(entry_name)
197
+ rule = entry(entry_name).fetch("root")
198
+ @compiled ||= {}
199
+ @compiled[rule] ||= compiler.atom_for(rule)
200
+ end
201
+
202
+ def verify_checksum!
203
+ stored = envelope["checksum"]
204
+ computed = Compiler.checksum(envelope)
205
+ return if stored == computed
206
+
207
+ raise ArtifactError,
208
+ "artifact checksum mismatch: stored #{stored.inspect}, " \
209
+ "computed #{computed.inspect}"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Parsanol
6
+ module PARG
7
+ # External test suites: *.pgtest files carrying the same test syntax
8
+ # as the inline test section (accept / reject / example). Suites are
9
+ # development-time corpora — they iterate a grammar against its
10
+ # binding requirements without being embedded in the artifact.
11
+ #
12
+ # suite smoke for entry identifier {
13
+ # accept "ISO 8601-1:2019"
14
+ # example "ISO/CD 12345" { stage: "draft20" }
15
+ # }
16
+ module Suite
17
+ module_function
18
+
19
+ # Load every *.pgtest file in a directory.
20
+ # Returns { suite_name => [Document::Test, ...] }.
21
+ def load(dir)
22
+ Dir.glob(File.join(dir, "*.pgtest")).to_h do |file|
23
+ [File.basename(file, ".pgtest"), read_file(file)]
24
+ end
25
+ end
26
+
27
+ def read_file(file)
28
+ content = File.read(file).gsub(/^#[^\n]*\n/, "")
29
+ header = content.match(/suite\s+\w+(?:\s+for\s+entry\s+(?<entry>\w+))?\s*\{/)
30
+ unless header
31
+ raise ParseError,
32
+ "#{file}: expected 'suite <name> [for <entry>] { ... }'"
33
+ end
34
+
35
+ body = content[header.end(0)..].sub(/\}\s*\z/, "")
36
+ document = Parser.new("grammar Suite version \"0.0.0\" {\n}\ntest {\n#{body}\n}\n").parse
37
+ document.tests.map do |test|
38
+ Document::Test.new(header[:entry] || test.entry, test.kind, test.input, test.expect)
39
+ end
40
+ end
41
+ end
42
+
43
+ # The binding-requirements schema: what a language-specific data model
44
+ # must implement for an entry. Derived from the declared bindings plus
45
+ # the observed example values.
46
+ module Schema
47
+ module_function
48
+
49
+ def from_artifact(artifact)
50
+ artifact.entries.to_h do |entry|
51
+ entry_data = artifact.entry(entry)
52
+ fields = entry_data["bindings"].to_h do |binding|
53
+ requirements = {
54
+ "type" => binding["type"],
55
+ "card" => binding["card"] || "1",
56
+ }
57
+ requirements["preprocess"] = binding["preprocess"] if binding["preprocess"]
58
+ [binding["path"], requirements]
59
+ end
60
+ examples = artifact.envelope.fetch("tests", [])
61
+ .select { |test| (test["kind"] == "example" && test["entry"].nil?) || test["entry"] == entry }
62
+ .map { |test| { "input" => test["input"], "captures" => test["expect"] } }
63
+ [entry, { "root" => entry_data["root"], "fields" => fields, "examples" => examples }]
64
+ end
65
+ end
66
+
67
+ # TypeScript materialization types: one interface per entry, one
68
+ # property per bound field. Cards ending in * become arrays;
69
+ # 0..1 becomes nullable.
70
+ def to_typescript(schema)
71
+ lines = ["// Generated by parsanol parg schema --ts — binding-requirements types."]
72
+ schema.each do |entry, data|
73
+ lines << ""
74
+ lines << "export interface #{ts_name(entry)} {"
75
+ data["fields"].each do |path, requirements|
76
+ lines << " #{ts_prop(path)}: #{ts_type(requirements)};"
77
+ end
78
+ lines << "}"
79
+ end
80
+ "#{lines.join("\n")}\n"
81
+ end
82
+
83
+ def ts_name(entry)
84
+ entry.split(/[_.]/).map(&:capitalize).join
85
+ end
86
+
87
+ def ts_prop(path)
88
+ leaf = path.include?("[]") ? path.split("].").last : path
89
+ leaf.gsub(/([A-Za-z0-9])_([a-z])/) do
90
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2).upcase}"
91
+ end
92
+ end
93
+
94
+ def ts_type(requirements)
95
+ base = case requirements["type"]
96
+ when "integer", "float" then "number"
97
+ when "boolean" then "boolean"
98
+ else "string"
99
+ end
100
+ if requirements["card"].to_s.end_with?("*")
101
+ "#{base}[]"
102
+ elsif requirements["card"] == "0..1"
103
+ "#{base} | null"
104
+ else
105
+ base
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end