parsanol 1.3.54 → 1.3.57

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/Cargo.lock +97 -23
  3. data/exe/parsanol +7 -0
  4. data/ext/parsanol_cdylib/Cargo.toml +1 -1
  5. data/ext/parsanol_native/Cargo.toml +2 -2
  6. data/ext/parsanol_native/build.rs +9 -0
  7. data/ext/parsanol_native/extconf.rb +10 -0
  8. data/lib/parsanol/atoms/base.rb +1 -1
  9. data/lib/parsanol/atoms/cut.rb +1 -1
  10. data/lib/parsanol/atoms/repetition.rb +13 -2
  11. data/lib/parsanol/cause.rb +1 -1
  12. data/lib/parsanol/error_reporter/contextual.rb +1 -1
  13. data/lib/parsanol/lazy_result.rb +12 -3
  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 -3
@@ -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
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parsanol
4
+ module PARG
5
+ # Applies an entry's artifact bindings to a parsanol-shape parse tree,
6
+ # producing the attribute hash that fills a lutaml-model class:
7
+ # capture collection -> preprocessing -> type cast -> path assignment.
8
+ module Bindings
9
+ module_function
10
+
11
+ def apply(artifact, entry, shape)
12
+ bindings = entry["bindings"] || []
13
+ captures = collect_captures(shape)
14
+ out = {}
15
+ scalar, arrays = bindings.partition { |binding| !binding["path"].to_s.include?("[]") }
16
+ scalar.each do |binding|
17
+ validate_path!(binding)
18
+ assign_scalar(out, artifact, binding, captures)
19
+ end
20
+ arrays.group_by { |binding| binding["path"].split("[]").first }.each do |prefix, group|
21
+ assign_array(out, artifact, prefix, group, captures)
22
+ end
23
+ out
24
+ end
25
+
26
+ def collect_captures(shape)
27
+ found = Hash.new { |hash, key| hash[key] = [] }
28
+ queue = [shape]
29
+ until queue.empty?
30
+ node = queue.shift
31
+ case node
32
+ when Hash
33
+ node.each do |key, value|
34
+ found[key.to_sym] << value
35
+ queue << value
36
+ end
37
+ when Array then queue.concat(node)
38
+ end
39
+ end
40
+ found
41
+ end
42
+
43
+ def assign_scalar(out, artifact, binding, captures)
44
+ value = captures[binding["capture"].to_sym].first
45
+ return if value.nil?
46
+
47
+ out[leaf_key(binding)] = finalize(artifact, binding, value)
48
+ end
49
+
50
+ def assign_array(out, artifact, prefix, group, captures)
51
+ count = group.map { |binding| captures[binding["capture"].to_sym].length }.max || 0
52
+ return if count.zero?
53
+
54
+ list = (out[prefix] ||= [])
55
+ count.times do |index|
56
+ element = {}
57
+ group.each do |binding|
58
+ value = captures[binding["capture"].to_sym][index]
59
+ next if value.nil?
60
+
61
+ element[leaf_key(binding)] = finalize(artifact, binding, value)
62
+ end
63
+ list << element
64
+ end
65
+ end
66
+
67
+ def validate_path!(binding)
68
+ path = binding["path"].to_s
69
+ return unless path.include?(".") || path.include?("[")
70
+
71
+ raise ArtifactError,
72
+ "binding #{binding['capture'].inspect}: nested path " \
73
+ "#{path.inspect} is not supported; bind the components instead"
74
+ end
75
+
76
+ def leaf_key(binding)
77
+ path = binding["path"].to_s
78
+ leaf = path.include?("[]") ? path.split("].").last : path
79
+ leaf.to_sym
80
+ end
81
+
82
+ def finalize(artifact, binding, value)
83
+ cast(preprocess(artifact, binding, value), binding["type"])
84
+ end
85
+
86
+ def preprocess(artifact, binding, value)
87
+ return value if binding["preprocess"].nil?
88
+
89
+ steps = artifact.envelope["preprocess"].fetch(binding["preprocess"]) do
90
+ raise ArtifactError,
91
+ "preprocess step #{binding['preprocess'].inspect} not declared"
92
+ end
93
+ steps.each do |step|
94
+ case step["op"]
95
+ when "table_lookup"
96
+ map = artifact.table_rows(step["table"]).to_h do |row|
97
+ [row[step["from"]].to_s, row[step["to"]]]
98
+ end
99
+ value = map[value.to_s] || value
100
+ else
101
+ raise ArtifactError, "unknown preprocess op #{step['op'].inspect}"
102
+ end
103
+ end
104
+ value
105
+ end
106
+
107
+ def cast(value, type)
108
+ case type
109
+ when "integer" then Integer(value)
110
+ when "float" then Float(value)
111
+ when "string"
112
+ # Parsanol::Slice (Ruby atom runtime) and native leaf objects are
113
+ # the shape contract's capture leaves; their to_s is the text.
114
+ scalar = value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
115
+ value == true || value == false || value.is_a?(Parsanol::Slice)
116
+ unless scalar
117
+ raise ArtifactError,
118
+ "cannot bind a #{value.class} capture to (string); " \
119
+ "captures must be scalar"
120
+ end
121
+
122
+ value.to_s
123
+ when "boolean" then value == true || value.to_s.casecmp("true").zero?
124
+ else value
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Parsanol
6
+ module PARG
7
+ # Command-line tool for authoring and testing PARG grammars without any
8
+ # language binding: compile, run the in-file tests, parse inputs
9
+ # interactively, and read the rule documentation.
10
+ #
11
+ # parsanol parg compile grammars/iso.parg -o iso.json
12
+ # parsanol parg test grammars/iso.parg
13
+ # parsanol parg parse grammars/iso.parg "ISO 8601-1:2019"
14
+ # parsanol parg repl iso.json
15
+ # parsanol parg doc grammars/iso.parg publisher
16
+ class CLI
17
+ USAGE = <<~TEXT
18
+ usage: parsanol parg <command> <grammar> [arguments]
19
+
20
+ commands:
21
+ compile <file.parg> [-o out.json] compile to an artifact envelope
22
+ test <file.parg|artifact.json> run the grammar's test section
23
+ parse <file> [--entry E] INPUT parse one input; prints the tree
24
+ and the bound captures
25
+ repl <file> [--entry E] interactive parse loop (blank
26
+ line quits, :quit exits)
27
+ doc <file> [rule] print rule documentation
28
+
29
+ options:
30
+ --tables DIR directory for alt-from-table data (default:
31
+ the grammar file's directory)
32
+ TEXT
33
+
34
+ def self.run(argv)
35
+ new(argv).run
36
+ end
37
+
38
+ def initialize(argv)
39
+ @argv = argv.dup
40
+ @tables_dir = nil
41
+ end
42
+
43
+ def run
44
+ if (index = @argv.index("--tables"))
45
+ @tables_dir = @argv.delete_at(index + 1)
46
+ @argv.delete_at(index)
47
+ end
48
+ @json = !!@argv.delete("--json")
49
+ command = @argv.shift
50
+ case command
51
+ when "compile" then compile
52
+ when "test" then test
53
+ when "schema" then schema
54
+ when "parse" then parse
55
+ when "repl" then repl
56
+ when "doc" then doc
57
+ when "lsp" then Lsp.new.run && 0
58
+ else
59
+ warn USAGE
60
+ 1
61
+ end
62
+ rescue Parsanol::PARG::Error, Parsanol::ParseFailed => e
63
+ warn "#{e.class}: #{e.message}"
64
+ 1
65
+ end
66
+
67
+ private
68
+
69
+ # parsanol-tree/v2 JSON: leaves become {value, line, column, offset,
70
+ # length}; Ruby atom-runtime leaves are Parsanol::Slice (to_s is the
71
+ # captured text, positions unknown at this layer).
72
+ def json_shape(node)
73
+ case node
74
+ when Hash then node.to_h { |k, v| [k.to_s, json_shape(v)] }
75
+ when Array then node.map { |item| json_shape(item) }
76
+ when Parsanol::Slice then { "value" => node.to_s }
77
+ else node
78
+ end
79
+ end
80
+
81
+ def compile
82
+ source = @argv.shift
83
+ out = flag_value("-o") || default_artifact_name(source)
84
+ envelope = compile_source(source).envelope
85
+ File.write(out, "#{JSON.generate(envelope)}\n")
86
+ puts "#{out} #{envelope['checksum']} warnings=#{envelope['lint']['order_warnings'].size}"
87
+ 0
88
+ end
89
+
90
+ def test
91
+ target = @argv.shift
92
+ if File.directory?(target)
93
+ return batch(target) { |file| artifact_for(file).run_tests }
94
+ end
95
+
96
+ artifact = artifact_for(target)
97
+ failures = artifact.run_tests
98
+ suite_dir = flag_value("--suite")
99
+ suites = suite_dir ? Parsanol::PARG::Suite.load(suite_dir) : {}
100
+ suites.each_value do |tests|
101
+ failures.concat(artifact.run_test_list(tests))
102
+ end
103
+ if failures.empty?
104
+ if @json
105
+ puts JSON.generate({ "ok" => true, "failures" => [] })
106
+ else
107
+ puts suites.empty? ? "all tests pass" : "all tests pass (#{suites.keys.join(', ')})"
108
+ end
109
+ 0
110
+ else
111
+ if @json
112
+ puts JSON.generate({ "ok" => false, "failures" => failures })
113
+ else
114
+ failures.each { |failure| warn failure }
115
+ end
116
+ 1
117
+ end
118
+ end
119
+
120
+ def schema
121
+ ts = @argv.delete("--ts")
122
+ artifact = artifact_for(@argv.shift)
123
+ schema = Parsanol::PARG::Schema.from_artifact(artifact)
124
+ if ts
125
+ puts Parsanol::PARG::Schema.to_typescript(schema)
126
+ else
127
+ puts JSON.pretty_generate(schema)
128
+ end
129
+ 0
130
+ end
131
+
132
+ def parse
133
+ file = @argv.shift
134
+ entry = flag_value("--entry")
135
+ input = @argv.join(" ")
136
+ if input.empty?
137
+ warn "usage: parsanol parg parse <file> [--entry E] INPUT"
138
+ return 1
139
+ end
140
+ artifact = artifact_for(file)
141
+ entry_name = entry || artifact.entries.first
142
+ shape = artifact.parse(entry_name, input)
143
+ if @json
144
+ puts JSON.generate({
145
+ "entry" => entry_name,
146
+ "shape" => json_shape(shape),
147
+ "bound" => artifact.apply_bindings(entry_name, shape),
148
+ })
149
+ else
150
+ puts "tree: #{shape.inspect}"
151
+ puts "captures: #{artifact.apply_bindings(entry_name, shape).inspect}"
152
+ end
153
+ 0
154
+ end
155
+
156
+ def repl
157
+ file = @argv.shift
158
+ entry = flag_value("--entry")
159
+ artifact = artifact_for(file)
160
+ entry_name = entry || artifact.entries.first
161
+ puts "parsanol parg repl — #{file} entry #{entry_name.inspect}; blank line quits"
162
+ loop do
163
+ print "> "
164
+ input = $stdin.gets
165
+ break if input.nil? || input.strip.empty?
166
+
167
+ begin
168
+ shape = artifact.parse(entry_name, input.rstrip)
169
+ puts "tree: #{shape.inspect}"
170
+ puts "captures: #{artifact.apply_bindings(entry_name, shape).inspect}"
171
+ rescue Parsanol::ParseFailed => e
172
+ puts "parse failed: #{e.message[0, 160]}"
173
+ end
174
+ end
175
+ 0
176
+ end
177
+
178
+ def doc
179
+ file = @argv.shift
180
+ rule = @argv.shift
181
+ docs = artifact_for(file).rule_docs
182
+ selected = rule ? { rule => docs[rule] } : docs
183
+ if selected.values.all?(&:nil?)
184
+ warn "no documentation#{" for #{rule.inspect}" if rule}"
185
+ return 1
186
+ end
187
+ selected.each do |name, text|
188
+ puts "#{name}:"
189
+ text.to_s.split("\n").each { |line| puts " #{line}" }
190
+ end
191
+ 0
192
+ end
193
+
194
+ def for_source_or_artifact
195
+ file = @argv.shift
196
+ artifact = artifact_for(file)
197
+ yield artifact
198
+ end
199
+
200
+ def artifact_for(file)
201
+ if file.end_with?(".json")
202
+ Parsanol::PARG::Artifact.load(file, tables_dir: @tables_dir || default_tables_dir(file))
203
+ else
204
+ artifact = compile_source(file)
205
+ Parsanol::PARG::Artifact.from_json(JSON.generate(artifact.envelope),
206
+ tables_dir: @tables_dir || default_tables_dir(file))
207
+ end
208
+ end
209
+
210
+ def compile_source(file)
211
+ tables = @tables_dir || default_tables_dir(file)
212
+ document = Parsanol::PARG::Parser.new(File.read(file)).parse
213
+ Parsanol::PARG::Imports.merge!(document, [File.dirname(file)])
214
+ Parsanol::PARG::Compiler.compile(document, tables_dir: tables)
215
+ end
216
+
217
+ def default_artifact_name(source)
218
+ "#{File.basename(source, '.parg')}.artifact.json"
219
+ end
220
+
221
+ # Batch mode: run a command over every *.parg in a directory.
222
+ def batch(target)
223
+ failures = {}
224
+ Dir.glob(File.join(target, "*.parg")).each do |file|
225
+ result = yield artifact_for(file)
226
+ failures[file] = result unless result.empty?
227
+ end
228
+ if failures.empty?
229
+ puts "all flavors pass"
230
+ 0
231
+ else
232
+ failures.each { |file, list| list.each { |f| warn "#{file}: #{f}" } }
233
+ 1
234
+ end
235
+ end
236
+
237
+ # Tables conventionally live in a "tables" directory beside the
238
+ # grammar (as a sibling of the grammars directory).
239
+ def default_tables_dir(file)
240
+ dir = File.dirname(file)
241
+ candidate = File.join(dir, "tables")
242
+ candidate = File.join(File.dirname(dir), "tables") unless File.directory?(candidate)
243
+ File.directory?(candidate) ? candidate : dir
244
+ end
245
+
246
+ def flag_value(flag)
247
+ @argv.delete_at(@argv.index(flag) + 1).tap { @argv.delete(flag) } if @argv.include?(flag)
248
+ end
249
+ end
250
+ end
251
+ end