parsanol 1.3.56-x86_64-linux → 1.3.57-x86_64-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 +4 -4
- data/exe/parsanol +7 -0
- data/lib/parsanol/3.2/parsanol_native.so +2 -2
- data/lib/parsanol/3.3/parsanol_native.so +2 -2
- data/lib/parsanol/3.4/parsanol_native.so +2 -2
- data/lib/parsanol/4.0/parsanol_native.so +2 -2
- data/lib/parsanol/native/libparsanol.so +0 -0
- data/lib/parsanol/native/serializer.rb +5 -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/version.rb +1 -1
- data/lib/parsanol/vm.rb +7 -5
- data/lib/parsanol.rb +3 -0
- data/parsanol.gemspec +3 -1
- metadata +31 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a611b335d9ec0bb11eb58195d1c4cbc53c7f44c18a49d7d91dc2dded1c1f22a
|
|
4
|
+
data.tar.gz: dcdf2f61d1c5673f953aa7532a4ac36a0470b10feccb833ac4a35f5f6c2b6c31
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6efce812c6ecca13bd6fd73c9c1f8214e459452bc964ded5083fcb6e25b23395c20ddc190f5ff9f3610e0d9a63a5b5019665daf86983d0b73ce9e0f99dbd5d6c
|
|
7
|
+
data.tar.gz: b4ebc57677baa4b6c8e51d1f0ce580b220c103643df44c14c7a374a8b687f49b74a773f4314a6d59bfca58e837e1f6f709c57feacf217b64f3cb3a91c1641eb3
|
data/exe/parsanol
ADDED
|
@@ -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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 39039928 bytes
|
|
4
|
+
sha256: fce80ea722af36f85c9486bd51038c509c036f2feed3fbdf211f009d2f5ed150
|
|
@@ -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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 39269840 bytes
|
|
4
|
+
sha256: eb7a20a4173dfee8593d53260427c53b0e3725db352b859588f62ba6e8d522fb
|
|
@@ -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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 39268832 bytes
|
|
4
|
+
sha256: 9edbb5d0963d2cad4a6485c50f9f5d719e02684698be05eb5c77457bb4fd6777
|
|
@@ -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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 39278776 bytes
|
|
4
|
+
sha256: c3b9c56f68adb58b20804796e1871b91db32205893198d6bbcee77ceabfbf251
|
|
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
|
-
#
|
|
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,
|
|
@@ -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
|