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.
- checksums.yaml +4 -4
- data/Cargo.lock +97 -23
- data/exe/parsanol +7 -0
- data/ext/parsanol_cdylib/Cargo.toml +1 -1
- data/ext/parsanol_native/Cargo.toml +2 -2
- data/ext/parsanol_native/build.rs +9 -0
- data/ext/parsanol_native/extconf.rb +10 -0
- data/lib/parsanol/atoms/base.rb +1 -1
- data/lib/parsanol/atoms/cut.rb +1 -1
- data/lib/parsanol/atoms/repetition.rb +13 -2
- data/lib/parsanol/cause.rb +1 -1
- data/lib/parsanol/error_reporter/contextual.rb +1 -1
- data/lib/parsanol/lazy_result.rb +12 -3
- data/lib/parsanol/native/serializer.rb +5 -2
- data/lib/parsanol/native/transformer.rb +12 -8
- data/lib/parsanol/native.rb +4 -2
- data/lib/parsanol/parg/artifact.rb +213 -0
- data/lib/parsanol/parg/authoring.rb +110 -0
- data/lib/parsanol/parg/bindings.rb +129 -0
- data/lib/parsanol/parg/cli.rb +251 -0
- data/lib/parsanol/parg/compiler.rb +465 -0
- data/lib/parsanol/parg/derive.rb +28 -0
- data/lib/parsanol/parg/document.rb +69 -0
- data/lib/parsanol/parg/error.rb +18 -0
- data/lib/parsanol/parg/frontend.rb +173 -0
- data/lib/parsanol/parg/import.rb +34 -0
- data/lib/parsanol/parg/importers/abnf.rb +299 -0
- data/lib/parsanol/parg/importers/ebnf.rb +201 -0
- data/lib/parsanol/parg/importers/pest.rb +314 -0
- data/lib/parsanol/parg/imports.rb +87 -0
- data/lib/parsanol/parg/lexer.rb +72 -0
- data/lib/parsanol/parg/lints.rb +171 -0
- data/lib/parsanol/parg/lsp.rb +208 -0
- data/lib/parsanol/parg/lutaml.rb +67 -0
- data/lib/parsanol/parg/node.rb +20 -0
- data/lib/parsanol/parg/parser.rb +458 -0
- data/lib/parsanol/parg/preprocess.rb +36 -0
- data/lib/parsanol/parg/render.rb +44 -0
- data/lib/parsanol/parg/selfhost.rb +49 -0
- data/lib/parsanol/parg/visitor.rb +64 -0
- data/lib/parsanol/parg.rb +38 -0
- data/lib/parsanol/parser.rb +1 -1
- data/lib/parsanol/pattern.rb +1 -1
- data/lib/parsanol/pool.rb +1 -1
- data/lib/parsanol/rope.rb +1 -1
- data/lib/parsanol/slice.rb +4 -6
- data/lib/parsanol/source/line_cache.rb +1 -1
- data/lib/parsanol/source.rb +2 -2
- data/lib/parsanol/version.rb +1 -1
- data/lib/parsanol/vm.rb +95 -5
- data/lib/parsanol.rb +9 -0
- data/parsanol.gemspec +3 -1
- 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
|